1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""As-of market context (evidence id M1) from sales with ``date <= cutoff`` only.34* municipality (same property class) over the 12 months ending at the cutoff: median price, median price/m2, n;5* monthly municipality index: median price/m2 by month (same class), expressed relative to the first month with6 at least ``MIN_MONTH_N`` sales (base = 100), months up to and including the cutoff month (the last month is7 partial — flagged); falls back to the region series when the municipality is too thin;8* region (same class): median price over 12 months and year-over-year change of that median;9* local liquidity: number of sales (all classes) within 3 km over the 12 months;10* local median assessment ratio (price / roll value in force) within 3 km, same class, 12 months (all classes11 when fewer than ``MIN_LOCAL_N``).12"""13from __future__ import annotations1415from datetime import date16from typing import Any1718import numpy as np19import polars as pl2021from ..config import load_config22from ..schemas import MarketContext23from .index import CLASS_CODE, AnalyticIndex, get_index, subject_to_dict, to_days2425WINDOW_DAYS = 36526LOCAL_RADIUS_KM = 3.027MIN_MONTH_N = 528MIN_LOCAL_N = 529MIN_MUN_N_12M = 20303132def _median(a: np.ndarray) -> float | None:33 a = a[~np.isnan(a)]34 return None if a.size == 0 else float(np.median(a))353637def _monthly_index(idx: AnalyticIndex, rows: np.ndarray, cutoff_days: int) -> list[dict[str, Any]]:38 if rows.size == 0:39 return []40 d = idx.date_days[rows]41 ppm2 = idx.ppm2[rows]42 ok = ~np.isnan(ppm2)43 if not ok.any():44 return []45 months = pl.Series(d[ok].astype(np.int32)).cast(pl.Date).dt.strftime("%Y-%m").to_numpy().astype(object)46 df = pl.DataFrame({"month": months, "ppm2": ppm2[ok]}).group_by("month").agg(pl.col("ppm2").median().alias("med"), pl.len().alias("n")).sort("month")47 df = df.filter(pl.col("n") >= MIN_MONTH_N)48 if df.height == 0:49 return []50 base = float(df["med"][0])51 cutoff_month = pl.Series([cutoff_days], dtype=pl.Int32).cast(pl.Date).dt.strftime("%Y-%m")[0]52 out = []53 for m, med, n in df.iter_rows():54 rec: dict[str, Any] = {"month": m, "index": round(100.0 * med / base, 1), "n": int(n)}55 if m == cutoff_month:56 rec["partial"] = True57 out.append(rec)58 return out596061def market_context(analytic: pl.DataFrame, subject: dict[str, Any] | pl.DataFrame, cutoff: date, cfg: dict[str, Any] | None = None) -> MarketContext:62 """Market context for ``subject`` as of ``cutoff`` (inclusive)."""63 cfg = cfg or load_config("data")64 s = subject_to_dict(subject)65 idx = get_index(analytic)66 cutoff_days = to_days(cutoff)67 start_12m = cutoff_days - WINDOW_DAYS + 168 cls_code = CLASS_CODE.get(s.get("property_class"), -1)69 mun = str(s["mun_code"])70 region = str(s["region_name"])7172 # municipality, same class, last 12 months73 mun_rows = idx.municipality_window(mun, start_12m, cutoff_days)74 mun_rows = mun_rows[idx.cls[mun_rows] == cls_code]75 mun_median = _median(idx.price[mun_rows]) if mun_rows.size else None76 mun_ppm2 = _median(idx.ppm2[mun_rows]) if mun_rows.size else None7778 # monthly index: municipality same-class series since the start of the table, else region series79 mun_hist = idx.municipality_window(mun, -10**9, cutoff_days)80 mun_hist = mun_hist[idx.cls[mun_hist] == cls_code]81 series = _monthly_index(idx, mun_hist, cutoff_days) if mun_rows.size >= MIN_MUN_N_12M else []82 index_level = "municipality"83 if len(series) < 6:84 reg_hist = idx.region_window(region, -10**9, cutoff_days)85 reg_hist = reg_hist[idx.cls[reg_hist] == cls_code]86 series = _monthly_index(idx, reg_hist, cutoff_days)87 index_level = "region"88 if series:89 series[0]["level"] = index_level9091 # region same class: 12 months and previous 12 months92 reg_rows = idx.region_window(region, start_12m, cutoff_days)93 reg_rows = reg_rows[idx.cls[reg_rows] == cls_code]94 reg_prev = idx.region_window(region, start_12m - WINDOW_DAYS, start_12m - 1)95 reg_prev = reg_prev[idx.cls[reg_prev] == cls_code]96 reg_median = _median(idx.price[reg_rows]) if reg_rows.size else None97 reg_prev_median = _median(idx.price[reg_prev]) if reg_prev.size else None98 yoy = None if reg_median is None or not reg_prev_median else round(reg_median / reg_prev_median - 1.0, 4)99100 # local 3 km101 if s.get("x_m") is not None:102 x, y = float(s["x_m"]), float(s["y_m"])103 else:104 from ..data.analytic_schema import project_xy105106 xx, yy = project_xy(np.array([s["lat"]]), np.array([s["lng"]]))107 x, y = float(xx[0]), float(yy[0])108 near = idx.within_radius(x, y, LOCAL_RADIUS_KM * 1000.0)109 d = idx.date_days[near]110 near = near[(d >= start_12m) & (d <= cutoff_days)]111 liquidity = int(near.size)112 near_cls = near[idx.cls[near] == cls_code]113 ratio_rows = near_cls if near_cls.size >= MIN_LOCAL_N else near114 local_ratio = _median(idx.ratio[ratio_rows]) if ratio_rows.size else None115116 return MarketContext(117 as_of=cutoff,118 municipality_median_price_12m=None if mun_median is None else round(mun_median, 0),119 municipality_median_ppm2_12m=None if mun_ppm2 is None else round(mun_ppm2, 1),120 municipality_n_sales_12m=int(mun_rows.size),121 municipality_price_index=series,122 region_median_price_12m=None if reg_median is None else round(reg_median, 0),123 region_yoy_change=yoy,124 local_liquidity_n_sales_3km_12m=liquidity,125 local_median_assessment_ratio_12m=None if local_ratio is None else round(local_ratio, 4),126 mortgage_rate_5y_fixed=None,127 )128