1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""Repeat-sales deflator and resale-implied values (paper §sec:fundamental; docs/fundamental_value.md §2).34Outputs (results/fundamental/):5 price_index_monthly.parquet municipality × class (and region × class) chained monthly log-price index6 resale_targets.parquet one row per analytic sale with a later resale of the same parcel (both legs from7 the full snapshot): resale price/date, months held, index change, implied value8 prior_targets.parquet backward direction (earlier sale brought forward to the sale date)9 resale_sample.json / .csv description of the resale sample (counts, holding periods, gaps) by class/region/split10 paper/tables/tab_resale_sample.tex LaTeX fragment of the description11 figures/fundamental_resale_gap.pdf distribution of (resale-implied log value − log price) by holding period1213 python3 scripts/50_repeat_sales.py [--min-n 5] [--window 3]14"""15from __future__ import annotations1617import argparse18import json19import sys20import time21from datetime import datetime, timezone22from pathlib import Path2324import matplotlib25matplotlib.use("Agg")26import matplotlib.pyplot as plt # noqa: E40227import numpy as np # noqa: E40228import pandas as pd # noqa: E40229import polars as pl # noqa: E4023031from agentic_valuation.config import FIGURES, PAPER, PROCESSED, RAW, RESULTS # noqa: E40232from agentic_valuation.evaluation.tables import escape, num, tabular # noqa: E40233from agentic_valuation.fundamental.repeat_sales import (describe_resale_sample, monthly_index, prior_targets, # noqa: E40234 resale_targets)3536OUT = RESULTS / "fundamental"37PALETTE = ["#1f5fa8", "#c8541c", "#0b7a5a", "#8b4e8d"]38GREY, INK = "#7a7a7a", "#222222"39plt.rcParams.update({"font.size": 8.5, "axes.spines.top": False, "axes.spines.right": False, "axes.edgecolor": GREY,40 "axes.grid": True, "grid.color": "#e3e3e3", "grid.linewidth": 0.5, "axes.axisbelow": True,41 "pdf.fonttype": 42, "legend.frameon": False, "text.color": INK, "font.family": "sans-serif"})424344def write_fragment(tex: str, path: Path) -> None:45 path.parent.mkdir(parents=True, exist_ok=True)46 path.write_text("% Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca\n% generated by scripts/50_repeat_sales.py — do not edit\n"47 + tex.rstrip() + "\n", encoding="utf-8")484950def sample_table(desc: dict) -> tuple[str, pd.DataFrame]:51 cols = ["n", "share_short_hold", "months_held_median", "annualised_log_return_median", "gap_mean", "gap_sd", "gap_abs_median"]52 header = ["Sample", "{N}", "{Hold $<$12 m}", "{Months}", "{Ann. return}", "{Mean gap}", "{SD gap}", "{Med. $|$gap$|$}"]53 rows, recs, mids = [], [], []5455 def add(label: str, b: dict) -> None:56 rows.append([escape(label)] + [num(b[c], 0 if c == "n" else (1 if c == "months_held_median" else 3)) for c in cols])57 recs.append({"sample": label, **{c: b[c] for c in cols}})5859 add("All pairs", desc["overall"])60 mids.append(len(rows) - 1)61 for k in ("condo", "house", "plex"):62 if k in desc["by_class"]:63 add(k.capitalize(), desc["by_class"][k])64 mids.append(len(rows) - 1)65 for k in ("Montréal CMA", "Québec CMA", "Outaouais", "other"):66 if k in desc["by_region"]:67 add(k, desc["by_region"][k])68 mids.append(len(rows) - 1)69 for k in ("<12", "12-24", "24-36", "36+"):70 if k in desc["by_hold_bucket"]:71 add(f"Held {k} months", desc["by_hold_bucket"][k])72 mids.append(len(rows) - 1)73 for k in ("train", "validation", "test", "holdout"):74 if k in desc["by_split"]:75 add(f"First sale in {k}", desc["by_split"][k])76 return tabular(header, rows, "@{}l" + "r" * len(cols) + "@{}", midrules_after=mids), pd.DataFrame(recs)777879def gap_figure(t: pd.DataFrame, path: Path) -> None:80 buckets = ["<12", "12-24", "24-36", "36+"]81 hb = pd.cut(t["months_held"], [0, 12, 24, 36, 72], labels=buckets, right=False)82 fig, axes = plt.subplots(1, 3, figsize=(7.2, 2.4), sharey=True)83 for ax, cls, col in zip(axes, ["condo", "house", "plex"], PALETTE):84 data = [t.loc[(t["property_class"] == cls) & (hb == b), "gap"].dropna().to_numpy() for b in buckets]85 bp = ax.boxplot(data, whis=(5, 95), showfliers=False, widths=0.55, patch_artist=True,86 medianprops={"color": INK, "linewidth": 1.1}, whiskerprops={"color": GREY}, capprops={"color": GREY})87 for patch in bp["boxes"]:88 patch.set(facecolor=col, alpha=0.55, edgecolor=col)89 ax.axhline(0, color=GREY, linewidth=0.7, linestyle="--")90 ax.set_xticks(range(1, len(buckets) + 1), buckets)91 ax.set_title(f"{cls} (n = {sum(len(d) for d in data):,})", fontsize=8.5, color=INK, loc="left")92 ax.set_xlabel("months between the two sales")93 axes[0].set_ylabel("resale-implied log value − log price")94 axes[0].set_ylim(-1.0, 1.0)95 fig.tight_layout()96 fig.savefig(path, bbox_inches="tight", pad_inches=0.02)97 plt.close(fig)9899100def main(argv: list[str] | None = None) -> int:101 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)102 ap.add_argument("--analytic", default=str(PROCESSED / "analytic.parquet"))103 ap.add_argument("--full", default=str(RAW / "wp22_clean_transactions.parquet"), help="full snapshot (both legs of a pair); '' → analytic only")104 ap.add_argument("--splits", default=str(PROCESSED / "splits.parquet"))105 ap.add_argument("--min-n", type=int, default=5)106 ap.add_argument("--window", type=int, default=3)107 ap.add_argument("--out", default=str(OUT))108 args = ap.parse_args(argv)109 out = Path(args.out)110 out.mkdir(parents=True, exist_ok=True)111 t0 = time.time()112113 analytic = pl.read_parquet(args.analytic)114 full = pl.read_parquet(args.full) if args.full and Path(args.full).exists() else None115 print(f"analytic rows: {len(analytic):,}; full snapshot rows: {len(full) if full is not None else 'n/a (analytic only)'}")116117 index = monthly_index(analytic, min_n=args.min_n, window=args.window)118 index.to_parquet(out / "price_index_monthly.parquet", index=False)119 mun_rows = index[index["level"] == "mun"]120 src = mun_rows["source"].value_counts().to_dict()121 print(f"index: {mun_rows[['mun_code', 'property_class']].drop_duplicates().shape[0]:,} municipality × class series, "122 f"{index['month_id'].nunique()} months; monthly changes from municipality {src.get('mun', 0):,}, region {src.get('region', 0):,}, carried {src.get('carry', 0):,} ({time.time() - t0:.1f} s)")123124 targets = resale_targets(analytic, full, index, min_n=args.min_n)125 prior = prior_targets(analytic, full, index, min_n=args.min_n)126 sp = Path(args.splits)127 if sp.exists():128 s = pl.read_parquet(sp).select(["tx_id", "split"]).to_pandas()129 targets = targets.merge(s, on="tx_id", how="left")130 prior = prior.merge(s, on="tx_id", how="left")131 targets.to_parquet(out / "resale_targets.parquet", index=False)132 prior.to_parquet(out / "prior_targets.parquet", index=False)133 print(f"resale targets: {len(targets):,} analytic sales with a later resale ({len(targets) / len(analytic):.1%}); "134 f"short holds < 12 m: {targets['short_hold'].mean():.1%}; later leg flagged: {targets['resale_flagged'].mean():.1%}; "135 f"prior targets: {len(prior):,} ({time.time() - t0:.1f} s)")136137 desc = describe_resale_sample(targets, analytic)138 desc["parameters"] = {"min_n": args.min_n, "window": args.window, "full_snapshot": bool(full is not None)}139 desc["index_sources_mun_level"] = {k: int(v) for k, v in src.items()}140 desc["generated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")141 desc["seconds"] = round(time.time() - t0, 1)142 (out / "resale_sample.json").write_text(json.dumps(desc, indent=2, ensure_ascii=False), encoding="utf-8")143 tex, csv = sample_table(desc)144 csv.to_csv(out / "resale_sample.csv", index=False)145 write_fragment(tex, PAPER / "tables" / "tab_resale_sample.tex")146 FIGURES.mkdir(parents=True, exist_ok=True)147 gap_figure(targets, FIGURES / "fundamental_resale_gap.pdf")148 ov = desc["overall"]149 print(f"gap (implied − log price): mean {ov['gap_mean']:+.3f}, sd {ov['gap_sd']:.3f}, median |gap| {ov['gap_abs_median']:.3f}; "150 f"median holding {ov['months_held_median']:.1f} months; done in {time.time() - t0:.1f} s")151 return 0152153154if __name__ == "__main__":155 sys.exit(main())156