Spaces:
Sleeping
Sleeping
| """Matched-pair DiD — closest control by Mahalanobis distance. | |
| Per docs/METHODOLOGY.md §7. For each treatment stock, find the closest control | |
| in the donor pool by ``(sector, β, amihud_60d)``. Distance is Mahalanobis on | |
| standardised continuous features; the categorical ``sector`` filter is applied | |
| first (only same-sector controls are eligible). If no same-sector control | |
| exists, falls back to nearest-by-features irrespective of sector and emits a | |
| warning column. | |
| Then ``did_panel`` builds a long Treated × Post panel and runs | |
| ``linearmodels.PanelOLS`` with entity + time fixed effects: | |
| R_{i,t} = α_i + γ_t + δ · (Treated_i · Post_t) + ε_{i,t} | |
| δ is the EQDP effect after the matched control absorbs everything we can | |
| match on. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import asdict, dataclass | |
| import numpy as np | |
| import pandas as pd | |
| from linearmodels.panel import PanelOLS | |
| class FeatureRow: | |
| """Per-ticker feature row used for matching.""" | |
| ticker: str | |
| sector: str | |
| beta: float | |
| amihud_60d: float | |
| class DiDResult: | |
| """Output of did_panel. ``delta`` is the Treated×Post coefficient.""" | |
| delta: float | |
| std_error: float | |
| t_stat: float | |
| p_value: float | |
| n_obs: int | |
| n_entities: int | |
| treated: tuple[str, ...] | |
| controls: tuple[str, ...] | |
| def _standardise(feat: pd.DataFrame, cols: list[str]) -> pd.DataFrame: | |
| """z-score the listed columns (ddof=0). Constant columns return zeros.""" | |
| out = feat.copy() | |
| for c in cols: | |
| sigma = out[c].std(ddof=0) | |
| mu = out[c].mean() | |
| out[c] = (out[c] - mu) / sigma if sigma > 0 else 0.0 | |
| return out | |
| def match_controls( | |
| treated: list[FeatureRow], | |
| control_pool: list[FeatureRow], | |
| *, | |
| cont_cols: tuple[str, ...] = ("beta", "amihud_60d"), | |
| require_same_sector: bool = True, | |
| used_unique: bool = False, | |
| ) -> dict[str, str]: | |
| """Return a mapping of treated ticker → matched control ticker. | |
| With ``require_same_sector=True``, the donor pool for each treated stock | |
| is filtered to its own sector; falls back to global pool if empty. | |
| With ``used_unique=True``, no control is reused across treated units — | |
| pick greedily in input order. | |
| """ | |
| if not treated: | |
| return {} | |
| if not control_pool: | |
| raise ValueError("control_pool is empty") | |
| full = pd.DataFrame([asdict(t) for t in treated + control_pool]) | |
| full = _standardise(full, list(cont_cols)) | |
| # Force the numeric columns to float to keep np.linalg happy (mixed-dtype | |
| # rows from .iterrows() yield object arrays otherwise). | |
| for c in cont_cols: | |
| full[c] = full[c].astype("float64") | |
| is_treated = pd.Series( | |
| [True] * len(treated) + [False] * len(control_pool), index=full.index | |
| ) | |
| treated_df = full[is_treated] | |
| pool_df = full[~is_treated] | |
| used: set[str] = set() | |
| out: dict[str, str] = {} | |
| for _, t in treated_df.iterrows(): | |
| candidates = pool_df | |
| if require_same_sector: | |
| same = pool_df[pool_df["sector"] == t["sector"]] | |
| candidates = same if not same.empty else pool_df | |
| if used_unique: | |
| candidates = candidates[~candidates["ticker"].isin(used)] | |
| if candidates.empty: | |
| continue | |
| # Euclidean on z-scored features (post-standardisation cov ≈ I, so | |
| # Euclidean ≡ Mahalanobis and avoids singular-cov edge cases). | |
| target = np.asarray( | |
| [float(t[c]) for c in cont_cols], dtype="float64" | |
| ) | |
| cand_pts = candidates[list(cont_cols)].to_numpy(dtype="float64") | |
| diff = cand_pts - target | |
| dist = np.linalg.norm(diff, axis=1) | |
| best_idx = int(np.argmin(dist)) | |
| chosen = str(candidates.iloc[best_idx]["ticker"]) | |
| out[str(t["ticker"])] = chosen | |
| used.add(chosen) | |
| return out | |
| def did_panel( | |
| panel: pd.DataFrame, | |
| treated: set[str], | |
| controls: set[str], | |
| *, | |
| event_t0: int = 0, | |
| outcome: str = "ret", | |
| ) -> DiDResult: | |
| """Run a Treated × Post DiD on the long panel. | |
| The panel must have columns ``ticker``, ``t``, and ``outcome``. Rows where | |
| ticker is neither treated nor a control are dropped. Post = (t >= event_t0). | |
| Returns the δ coefficient, its t-stat, and the per-entity sample. | |
| """ | |
| needed = {"ticker", "t", outcome} | |
| missing = needed - set(panel.columns) | |
| if missing: | |
| raise KeyError(f"panel missing columns: {sorted(missing)}") | |
| universe = treated | controls | |
| df = panel[panel["ticker"].isin(universe)].copy() | |
| df = df.dropna(subset=[outcome]) | |
| if df.empty: | |
| raise ValueError("no rows match treated ∪ controls in panel") | |
| df["treated"] = df["ticker"].isin(treated).astype(int) | |
| df["post"] = (df["t"] >= event_t0).astype(int) | |
| df["interact"] = df["treated"] * df["post"] | |
| df = df.set_index(["ticker", "t"]) | |
| df = df.sort_index() | |
| # PanelOLS with entity + time fixed effects. | |
| mod = PanelOLS( | |
| dependent=df[[outcome]], | |
| exog=df[["interact"]], | |
| entity_effects=True, | |
| time_effects=True, | |
| ) | |
| res = mod.fit(cov_type="clustered", cluster_entity=True) | |
| delta = float(res.params["interact"]) | |
| se = float(res.std_errors["interact"]) | |
| tstat = float(res.tstats["interact"]) | |
| pval = float(res.pvalues["interact"]) | |
| return DiDResult( | |
| delta=delta, | |
| std_error=se, | |
| t_stat=tstat, | |
| p_value=pval, | |
| n_obs=int(res.nobs), | |
| n_entities=df.index.get_level_values("ticker").nunique(), | |
| treated=tuple(sorted(treated)), | |
| controls=tuple(sorted(controls)), | |
| ) | |