SPB Git

spb/wp26_uqo

Public
8.4 KB · 156 lines python
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
Files · main
Menu

Theme