# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca """As-of market context (evidence id M1) from sales with ``date <= cutoff`` only. * municipality (same property class) over the 12 months ending at the cutoff: median price, median price/m2, n; * monthly municipality index: median price/m2 by month (same class), expressed relative to the first month with at least ``MIN_MONTH_N`` sales (base = 100), months up to and including the cutoff month (the last month is partial — flagged); falls back to the region series when the municipality is too thin; * region (same class): median price over 12 months and year-over-year change of that median; * local liquidity: number of sales (all classes) within 3 km over the 12 months; * local median assessment ratio (price / roll value in force) within 3 km, same class, 12 months (all classes when fewer than ``MIN_LOCAL_N``). """ from __future__ import annotations from datetime import date from typing import Any import numpy as np import polars as pl from ..config import load_config from ..schemas import MarketContext from .index import CLASS_CODE, AnalyticIndex, get_index, subject_to_dict, to_days WINDOW_DAYS = 365 LOCAL_RADIUS_KM = 3.0 MIN_MONTH_N = 5 MIN_LOCAL_N = 5 MIN_MUN_N_12M = 20 def _median(a: np.ndarray) -> float | None: a = a[~np.isnan(a)] return None if a.size == 0 else float(np.median(a)) def _monthly_index(idx: AnalyticIndex, rows: np.ndarray, cutoff_days: int) -> list[dict[str, Any]]: if rows.size == 0: return [] d = idx.date_days[rows] ppm2 = idx.ppm2[rows] ok = ~np.isnan(ppm2) if not ok.any(): return [] months = pl.Series(d[ok].astype(np.int32)).cast(pl.Date).dt.strftime("%Y-%m").to_numpy().astype(object) df = pl.DataFrame({"month": months, "ppm2": ppm2[ok]}).group_by("month").agg(pl.col("ppm2").median().alias("med"), pl.len().alias("n")).sort("month") df = df.filter(pl.col("n") >= MIN_MONTH_N) if df.height == 0: return [] base = float(df["med"][0]) cutoff_month = pl.Series([cutoff_days], dtype=pl.Int32).cast(pl.Date).dt.strftime("%Y-%m")[0] out = [] for m, med, n in df.iter_rows(): rec: dict[str, Any] = {"month": m, "index": round(100.0 * med / base, 1), "n": int(n)} if m == cutoff_month: rec["partial"] = True out.append(rec) return out def market_context(analytic: pl.DataFrame, subject: dict[str, Any] | pl.DataFrame, cutoff: date, cfg: dict[str, Any] | None = None) -> MarketContext: """Market context for ``subject`` as of ``cutoff`` (inclusive).""" cfg = cfg or load_config("data") s = subject_to_dict(subject) idx = get_index(analytic) cutoff_days = to_days(cutoff) start_12m = cutoff_days - WINDOW_DAYS + 1 cls_code = CLASS_CODE.get(s.get("property_class"), -1) mun = str(s["mun_code"]) region = str(s["region_name"]) # municipality, same class, last 12 months mun_rows = idx.municipality_window(mun, start_12m, cutoff_days) mun_rows = mun_rows[idx.cls[mun_rows] == cls_code] mun_median = _median(idx.price[mun_rows]) if mun_rows.size else None mun_ppm2 = _median(idx.ppm2[mun_rows]) if mun_rows.size else None # monthly index: municipality same-class series since the start of the table, else region series mun_hist = idx.municipality_window(mun, -10**9, cutoff_days) mun_hist = mun_hist[idx.cls[mun_hist] == cls_code] series = _monthly_index(idx, mun_hist, cutoff_days) if mun_rows.size >= MIN_MUN_N_12M else [] index_level = "municipality" if len(series) < 6: reg_hist = idx.region_window(region, -10**9, cutoff_days) reg_hist = reg_hist[idx.cls[reg_hist] == cls_code] series = _monthly_index(idx, reg_hist, cutoff_days) index_level = "region" if series: series[0]["level"] = index_level # region same class: 12 months and previous 12 months reg_rows = idx.region_window(region, start_12m, cutoff_days) reg_rows = reg_rows[idx.cls[reg_rows] == cls_code] reg_prev = idx.region_window(region, start_12m - WINDOW_DAYS, start_12m - 1) reg_prev = reg_prev[idx.cls[reg_prev] == cls_code] reg_median = _median(idx.price[reg_rows]) if reg_rows.size else None reg_prev_median = _median(idx.price[reg_prev]) if reg_prev.size else None yoy = None if reg_median is None or not reg_prev_median else round(reg_median / reg_prev_median - 1.0, 4) # local 3 km if s.get("x_m") is not None: x, y = float(s["x_m"]), float(s["y_m"]) else: from ..data.analytic_schema import project_xy xx, yy = project_xy(np.array([s["lat"]]), np.array([s["lng"]])) x, y = float(xx[0]), float(yy[0]) near = idx.within_radius(x, y, LOCAL_RADIUS_KM * 1000.0) d = idx.date_days[near] near = near[(d >= start_12m) & (d <= cutoff_days)] liquidity = int(near.size) near_cls = near[idx.cls[near] == cls_code] ratio_rows = near_cls if near_cls.size >= MIN_LOCAL_N else near local_ratio = _median(idx.ratio[ratio_rows]) if ratio_rows.size else None return MarketContext( as_of=cutoff, municipality_median_price_12m=None if mun_median is None else round(mun_median, 0), municipality_median_ppm2_12m=None if mun_ppm2 is None else round(mun_ppm2, 1), municipality_n_sales_12m=int(mun_rows.size), municipality_price_index=series, region_median_price_12m=None if reg_median is None else round(reg_median, 0), region_yoy_change=yoy, local_liquidity_n_sales_3km_12m=liquidity, local_median_assessment_ratio_12m=None if local_ratio is None else round(local_ratio, 4), mortgage_rate_5y_fixed=None, )