"""Replication entry point for F6-conf-02. Run from workspace root: ~/wp29/venv/bin/python out/analysis.py Reads only data/, writes out/results.json, out/figure1.png, out/table1.csv, out/exclusions.csv. No randomness is used (random_seed = null).""" import json, os import numpy as np import pandas as pd from scipy import stats import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt DATA = "data/gc5y_daily.csv" OUT = "out" PRE_N = 120 CUT_DATE = "2020-03-04" os.makedirs(OUT, exist_ok=True) raw = pd.read_csv(DATA) n_raw = len(raw) d = raw.copy() d["date_dt"] = pd.to_datetime(d["date"], format="%Y-%m-%d") issues = [] excl = [] # ---- audit ---- n_na = int(d[["date", "yield_5y", "post_cut"]].isna().any(axis=1).sum()) n_dup = int(d["date"].duplicated().sum()) monotone = bool(d["date_dt"].is_monotonic_increasing) n_treated = int((d["post_cut"] == 1).sum()) bad_flag = int((~d["post_cut"].isin([0, 1])).sum()) flag_mismatch = int(((d["date_dt"] >= CUT_DATE).astype(int) != d["post_cut"]).sum()) gaps = d["date_dt"].diff().dt.days n_gaps_gt3 = int((gaps > 3).sum()) if n_treated == 0: issues.append({ "issue": f"Coverage: file spans {d['date'].iloc[0]} to {d['date'].iloc[-1]} (retrieval end_date=2019-12-31 per data/README.md); " f"it ends before the 2020-03-04 cut and contains 0 rows with post_cut = 1.", "handling": "Treated window is empty, so the post-cut mean, the pre-window (120 days before the first flagged day) and the effect " "are undefined. Declared premise_invalid / infeasible; inestimable keys set to null. No proxy (e.g. placebo date) substituted."}) issues.append({"issue": f"Missing values: {n_na} rows with missing fields; duplicate dates: {n_dup}; dates monotone increasing: {monotone}.", "handling": "No action needed; no rows removed."}) issues.append({"issue": f"post_cut values outside {{0,1}}: {bad_flag}; rows where post_cut disagrees with date >= {CUT_DATE}: {flag_mismatch}.", "handling": "post_cut used exactly as provided (not redefined)."}) issues.append({"issue": f"Calendar gaps: {n_gaps_gt3} consecutive-row gaps longer than 3 calendar days (max {int(gaps.max())} days), consistent with holidays/non-publication days.", "handling": "Per task rule, one-day changes are taken between consecutive rows regardless of calendar gap."}) # ---- changes and baseline ---- d["chg_bp"] = 100.0 * d["yield_5y"].diff() chg = d["chg_bp"].dropna() excl.append(dict(step=1, rule="first row has no previous row: one-day change undefined", n_before=n_raw, n_after=len(chg), n_removed=n_raw - len(chg), rationale="change = 100*(y_t - y_{t-1}) needs a prior row")) baseline_metric = float(chg.mean()) # ---- effect (only if treated rows exist) ---- effect = lo = hi = model_metric = None n_analysed = None if n_treated > 0: first = int(np.flatnonzero(d["post_cut"].values == 1)[0]) post = d.loc[d["post_cut"] == 1, "chg_bp"].dropna() pre = d["chg_bp"].iloc[max(first - PRE_N, 0):first].dropna() if len(pre) >= 2 and len(post) >= 2: effect = float(post.mean() - pre.mean()) v1, v0 = post.var(ddof=1) / len(post), pre.var(ddof=1) / len(pre) se = np.sqrt(v1 + v0) df = (v1 + v0) ** 2 / (v1 ** 2 / (len(post) - 1) + v0 ** 2 / (len(pre) - 1)) t = stats.t.ppf(0.975, df) lo, hi = float(effect - t * se), float(effect + t * se) resid = np.concatenate([post - post.mean(), pre - pre.mean()]) model_metric = float(np.sqrt(np.mean(resid ** 2))) n_analysed = int(len(post) + len(pre)) excl.append(dict(step=2, rule="keep 120 pre rows before first flagged day + all post_cut=1 rows", n_before=len(chg), n_after=n_analysed, n_removed=len(chg) - n_analysed, rationale="estimand windows")) else: excl.append(dict(step=2, rule="keep 120 pre rows before first flagged day + all post_cut=1 rows", n_before=len(chg), n_after=0, n_removed=len(chg), rationale="no post_cut=1 rows: windows undefined, nothing analysed")) if effect is None: conclusion, feas = "premise_invalid", "infeasible" reason = ("data/gc5y_daily.csv ends on " + d["date"].iloc[-1] + " and has no rows with post_cut = 1; the post-cut window " "and the 120-day pre-window defined relative to the first flagged day do not exist, so the effect cannot be computed.") else: conclusion = "positive_effect" if lo > 0 else ("negative_effect" if hi < 0 else "no_detectable_effect") feas, reason = "complete", None res = { "task_id": "F6-conf-02", "sample_start": d["date"].iloc[0], "sample_end": d["date"].iloc[-1], "n_treated": n_treated, "baseline_metric": baseline_metric, "model_metric": model_metric, "effect_estimate": effect, "effect_ci_low": lo, "effect_ci_high": hi, "conclusion": conclusion, "feasibility": feas, "feasibility_reason": reason, "data_issues": issues, "n_input_rows": {"gc5y_daily.csv": n_raw}, "n_analysed": n_analysed, "random_seed": None, "baseline_n_changes": int(len(chg)), "baseline_sd_change_bp": float(chg.std(ddof=1)), } with open(f"{OUT}/results.json", "w") as f: json.dump(res, f, indent=2) pd.DataFrame(excl, columns=["step", "rule", "n_before", "n_after", "n_removed", "rationale"]).to_csv(f"{OUT}/exclusions.csv", index=False) # ---- table ---- d["year"] = d["date_dt"].dt.year tab = d.groupby("year").agg(n_days=("yield_5y", "size"), mean_yield=("yield_5y", "mean"), mean_change_bp=("chg_bp", "mean")).reset_index() tab.round(4).to_csv(f"{OUT}/table1.csv", index=False) # ---- figure ---- fig, ax = plt.subplots(figsize=(9, 4)) ax.plot(d["date_dt"], d["yield_5y"], lw=0.8) ax.set_title("Government of Canada 5-year benchmark yield (BD.CDN.5YR.DQ.YLD)\nFile ends " + d["date"].iloc[-1] + "; no post_cut = 1 rows (cut date 2020-03-04 not covered)", fontsize=9) ax.set_ylabel("Yield (%)"); ax.set_xlabel("Date"); ax.grid(alpha=0.3) fig.tight_layout(); fig.savefig(f"{OUT}/figure1.png", dpi=120) print(f"sample {res['sample_start']}..{res['sample_end']}, n_treated={n_treated}, baseline={baseline_metric:.4f} bp, " f"conclusion={conclusion}, feasibility={feas}")