# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca """CLEARLY LABELLED SYNTHETIC DATA. Generates a fake Quebec-like transaction table with the exact column contract of the analytic layer (`analytic_schema.ANALYTIC_COLUMNS`) so that the whole pipeline (features, comparables, baselines, agents, evaluation, tables) runs without proprietary data or API keys. The data-generating process is a hedonic log-price model with municipality effects, a monthly market index, spatial smoothing and i.i.d. noise. Nothing in it is calibrated to real Quebec prices beyond rough orders of magnitude. Never report synthetic results as empirical findings. """ from __future__ import annotations import math from datetime import date, timedelta import numpy as np import polars as pl from ..config import SEED, SYNTHETIC REGIONS = { "Montréal": (45.52, -73.62, 1.00, "metro"), "Capitale-Nationale": (46.82, -71.23, 0.80, "metro"), "Outaouais": (45.45, -75.72, 0.78, "metro"), "Montérégie": (45.40, -73.30, 0.85, "suburban"), "Laurentides": (45.80, -74.00, 0.75, "suburban"), "Estrie": (45.40, -71.90, 0.62, "rural"), "Mauricie": (46.35, -72.55, 0.55, "rural"), "Bas-Saint-Laurent": (48.45, -68.52, 0.45, "rural"), } CLASSES = ["house", "condo", "plex"] def generate(n: int = 20_000, seed: int = SEED, start: date = date(2021, 1, 1), end: date = date(2026, 10, 7)) -> pl.DataFrame: rng = np.random.default_rng(seed) regs = list(REGIONS) reg_p = np.array([0.30, 0.14, 0.10, 0.18, 0.10, 0.07, 0.06, 0.05]) region = rng.choice(regs, size=n, p=reg_p) n_mun = 6 mun_idx = rng.integers(0, n_mun, size=n) mun_eff = {(r, k): rng.normal(0, 0.15) for r in regs for k in range(n_mun)} cls = rng.choice(CLASSES, size=n, p=[0.62, 0.22, 0.16]) days = (end - start).days t = rng.integers(0, days + 1, size=n) sale_date = np.array([start + timedelta(days=int(d)) for d in t]) month_i = t / 30.44 # market index: boom 2021-22, dip 2023, recovery 2024-26, + seasonal idx = 0.012 * np.minimum(month_i, 18) - 0.004 * np.clip(month_i - 18, 0, 12) + 0.004 * np.clip(month_i - 30, 0, None) idx += 0.015 * np.sin(2 * math.pi * (month_i % 12) / 12) area = np.where(cls == "condo", rng.lognormal(math.log(85), 0.3, n), np.where(cls == "plex", rng.lognormal(math.log(220), 0.3, n), rng.lognormal(math.log(140), 0.3, n))) land = np.where(cls == "condo", np.nan, rng.lognormal(math.log(600), 0.6, n)) yb = np.clip(rng.normal(1980, 22, n).round(), 1850, 2026).astype(int) storeys = np.where(cls == "condo", 1.0, rng.choice([1.0, 1.5, 2.0], size=n, p=[0.45, 0.2, 0.35])) n_units = np.where(cls == "plex", rng.choice([2, 3, 4, 5], size=n, p=[0.5, 0.3, 0.15, 0.05]), 1) lat = np.array([REGIONS[r][0] for r in region]) + rng.normal(0, 0.08, n) + 0.03 * mun_idx lng = np.array([REGIONS[r][1] for r in region]) + rng.normal(0, 0.10, n) + 0.03 * mun_idx # spatial field: smooth function of coordinates spatial = 0.10 * np.sin(lat * 40) * np.cos(lng * 25) reg_level = np.array([math.log(REGIONS[r][2]) for r in region]) mun = np.array([mun_eff[(r, k)] for r, k in zip(region, mun_idx)]) cls_eff = np.where(cls == "condo", -0.05, np.where(cls == "plex", 0.25, 0.0)) logp = ( math.log(420_000) + reg_level + mun + cls_eff + idx + spatial + 0.72 * (np.log(area) - math.log(140)) + np.where(np.isnan(land), 0, 0.08 * (np.log(np.nan_to_num(land, nan=600)) - math.log(600))) - 0.0025 * (2026 - yb) + 0.05 * (storeys - 1) + 0.12 * np.log(n_units) + rng.normal(0, 0.14, n) ) price = np.round(np.exp(logp), -3) # assessment roll: value ~ price at the roll reference date (2 yrs stale) with noise, ratio ~ 1.2-1.4 in 2024-26 roll_total = np.round(price * np.exp(-idx + 0.02 - 0.3 * np.clip((month_i - 36) / 36, 0, 1)) * rng.lognormal(0, 0.12, n), -2) roll_land = np.round(roll_total * np.where(cls == "condo", 0.12, rng.uniform(0.2, 0.45, n)), -2) # repeat sales: ~8 % of parcels sell twice parcel = np.array([f"S{rng.integers(0, int(n * 0.92)):07d}" for _ in range(n)]) mun_name = np.array([f"{r[:4].upper()}-{k:02d}" for r, k in zip(region, mun_idx)]) df = pl.DataFrame( { "tx_id": [f"SYN{i:07d}" for i in range(n)], "parcel_id": parcel, "date": [d.isoformat() for d in sale_date], "price": price, "lat": np.round(lat, 5), "lng": np.round(lng, 5), "region_name": region, "mun_code": [f"{abs(hash(m)) % 90000 + 10000}" for m in mun_name], "mun_name": mun_name, "borough_name": [None] * n, "urban_class": [REGIONS[r][3] for r in region], "property_class": cls, "subtype": np.where(cls == "condo", "integrated", rng.choice(["detached", "semi", "row"], size=n, p=[0.75, 0.15, 0.10])), "floor_area_m2": np.round(area, 1), "land_area_m2": np.round(land, 1), "year_built": yb, "storeys": storeys, "n_units": n_units, "roll_land_value": roll_land, "roll_building_value": roll_total - roll_land, "roll_total_value": roll_total, "roll_reference_date": ["2022-07-01"] * n, "new_construction": (yb >= np.array([d.year for d in sale_date])), "exclude_reason": [None] * n, } ).with_columns(pl.col("date").str.to_date("%Y-%m-%d")) return df.sort("date") def write_default(n: int = 20_000) -> pl.DataFrame: SYNTHETIC.mkdir(parents=True, exist_ok=True) df = generate(n) df.write_parquet(SYNTHETIC / "synthetic_transactions.parquet") (SYNTHETIC / "README.md").write_text( "# SYNTHETIC DATA — not real transactions\n\nGenerated by `agentic_valuation.data.synthetic.generate` " f"(seed {SEED}, n={n}). Column contract identical to the analytic layer. Use only for tests and smoke runs.\n" ) return df if __name__ == "__main__": d = write_default() print(d.shape, d["date"].min(), d["date"].max(), d["price"].median())