1"""Model D — competitor price-response event study / distributed lags (H3, association only).23Outcome: sustained own price cut on day t (S2 listing-days). Treatment: indicator of4>= 1 sustained cut among local competitors (same make-model, year +-1, other dealer,5within 25 km; 10/50 km robustness) on day t-k, k = -7..+14. Linear probability6model with market x week and make-model-year x week fixed effects (DECISIONS D-011),7SEs clustered by dealer (primary) and by region (secondary). Placebo: competitor-cut8series permuted across listings within make-model (geography broken).9"""10from __future__ import annotations1112import json13import time1415import numpy as np16import pandas as pd17import polars as pl18import pyfixest as pf1920from . import config as C2122OUT = C.RESULTS / "competition"23LEADS, LAGS = 7, 14242526def load(radius: str = "25") -> pd.DataFrame:27 RS = pl.read_parquet(C.PROCESSED / "risk_set.parquet").filter(pl.col("s2_dynamic"))28 L = pl.read_parquet(C.PROCESSED / "listings.parquet").select(["listing_id", "dealer_id", "make", "model", "year", "region", "powertrain", "body_filled", "source_class"])29 RS = RS.join(L, on="listing_id", how="left").sort(["listing_id", "t"])30 RS = RS.with_columns([(pl.col(f"ccut_{radius}") > 0).cast(pl.Int8).alias("cc"), pl.col("first_cut").fill_null(0)])31 shifts = []32 for k in range(-LEADS, LAGS + 1):33 name = f"cc_m{abs(k)}" if k < 0 else f"cc_p{k}" # m = lead (future competitor cut), p = lag (past)34 shifts.append(pl.col("cc").shift(k).over("listing_id").fill_null(0).alias(name))35 RS = RS.with_columns(shifts)36 RS = RS.with_columns([37 (pl.col("t") // 7).alias("week"),38 pl.concat_str([pl.col("region").fill_null("(missing)"), pl.lit("|"), (pl.col("t") // 7).cast(pl.Utf8)]).alias("mkt_week"),39 pl.concat_str([pl.col("make"), pl.lit("|"), pl.col("model"), pl.lit("|"), pl.col("year").cast(pl.Utf8), pl.lit("|"), (pl.col("t") // 7).cast(pl.Utf8)]).alias("mmy_week"),40 pl.concat_str([pl.col("make"), pl.lit("|"), pl.col("model")]).alias("mm"),41 pl.col("listing_age").clip(upper_bound=60).alias("age_c"),42 (pl.col(f"dens_{radius}") + 1).log().alias("ldens"),43 ])44 df = RS.to_pandas()45 for c in ["mkt_week", "mmy_week", "mm", "dealer_id", "region"]:46 df[c] = pd.factorize(df[c])[0]47 return df484950def lag_names():51 return [f"cc_m{k}" for k in range(LEADS, 0, -1)] + [f"cc_p{k}" for k in range(0, LAGS + 1)]525354def fit(df: pd.DataFrame, y: str = "cut", fe: str = "mkt_week + mmy_week", cluster: str = "dealer_id", name: str = "main") -> dict:55 t0 = time.time()56 rhs = " + ".join(lag_names()) + " + ldens + C(age_c)"57 m = pf.feols(f"{y} ~ {rhs} | {fe}", data=df, vcov={"CRV1": cluster}, fixef_rm="none")58 t = m.tidy().reset_index(); t = t.rename(columns={t.columns[0]: "term"})59 t = t[t["term"].isin(lag_names())].copy()60 t["k"] = t["term"].map(lambda s: -int(s[4:]) if s.startswith("cc_m") else int(s[4:]))61 t = t.sort_values("k"); t.to_csv(OUT / f"eventstudy_{name}.csv", index=False)62 names = list(m._coefnames)63 def joint(cols):64 idx = [names.index(c) for c in cols]; b = np.asarray(m.coef())[idx]; V = np.asarray(m._vcov)[np.ix_(idx, idx)]65 W = float(b @ np.linalg.solve(V, b)); from scipy.stats import chi266 return {"stat": W, "df": len(idx), "p": float(chi2.sf(W, len(idx))), "sum": float(b.sum())}67 leads = [f"cc_m{k}" for k in range(1, LEADS + 1)]; lags = [f"cc_p{k}" for k in range(0, LAGS + 1)]68 out = {"n": int(m._N), "base_rate": float(df[y].mean()), "fe": fe, "cluster": cluster, "seconds": round(time.time() - t0, 1),69 "leads_joint": joint(leads), "lags_joint": joint(lags), "lag0_7_sum": joint([f"cc_p{k}" for k in range(0, 8)]),70 "coef": {r.term: {"b": float(r.Estimate), "se": float(r["Std. Error"]), "lo": float(r["2.5%"]), "hi": float(r["97.5%"])} for _, r in t.iterrows()}}71 return out727374def placebo(df: pd.DataFrame, seed: int = 36) -> dict:75 """Permute the competitor-cut series across listings within make-model (breaks geography)."""76 rng = np.random.default_rng(seed)77 cols = lag_names()78 first = df.groupby("listing_id")["mm"].first()79 perm = {}80 for mm, ids in first.groupby(first).groups.items():81 ids = np.array(list(ids)); sh = ids.copy(); rng.shuffle(sh); perm.update(dict(zip(ids, sh)))82 # map each listing's series to another listing's series of the same make-model, aligned on listing_age83 src = df[["listing_id", "listing_age"] + cols].copy(); src["listing_id"] = src["listing_id"].map(perm)84 d2 = df.drop(columns=cols).merge(src, on=["listing_id", "listing_age"], how="left")85 d2[cols] = d2[cols].fillna(0)86 return fit(d2, name="placebo_permuted")878889def selection_check(df: pd.DataFrame) -> dict:90 """Do listings that ever receive a competitor cut differ at entry? (selection into treatment)"""91 g = df.groupby("listing_id").agg(any_cc=("cc", "max"), ldens=("ldens", "first"), logp=("logp", "first"), pp=("pricepos_25", "first"))92 out = {}93 for v in ["ldens", "logp", "pp"]:94 a, b = g.loc[g.any_cc == 1, v].dropna(), g.loc[g.any_cc == 0, v].dropna()95 out[v] = {"mean_treated": float(a.mean()), "mean_untreated": float(b.mean()), "std_diff": float((a.mean() - b.mean()) / np.sqrt((a.var() + b.var()) / 2))}96 out["share_ever_treated"] = float(g.any_cc.mean())97 return out9899100def run() -> dict:101 OUT.mkdir(parents=True, exist_ok=True)102 res = {}103 df = load("25")104 print("main", flush=True); res["main_25km"] = fit(df)105 print("cluster region", flush=True); res["main_25km_cluster_region"] = fit(df, cluster="region", name="main_cluster_region")106 print("first cut only", flush=True)107 res["firstcut_25km"] = fit(df[df["in_first_cut_risk"]], y="first_cut", name="firstcut")108 print("no FE / coarse", flush=True); res["coarse_fe"] = fit(df, fe="region + week", name="coarse_fe")109 print("placebo", flush=True); res["placebo_permuted"] = placebo(df)110 res["selection"] = selection_check(df)111 for r in ("10", "50"):112 print("radius", r, flush=True); d = load(r); res[f"main_{r}km"] = fit(d, name=f"main_{r}km")113 json.dump(res, open(OUT / "summary.json", "w"), indent=2, default=float)114 return res115116117if __name__ == "__main__":118 print(json.dumps(run(), indent=2, default=float))119