"""Management Change Alpha Analysis — Phase 1 validation. Empirically tests whether management_change events produce tradeable PEAD-like signals (initial under-reaction followed by drift). Analyses: 1. Directional follow-through decomposition by initial reaction sign 2. Forward return profiles (mean, median, win rate) by holding period 3. MAE/MFE profiles 4. Filter effect simulation (document_quality, volume_ratio, market_cap) 5. Temporal distribution (monthly, quarterly, earnings-season overlap) Usage: python -m apps.tools.management_change_analysis \ --snapshot-dir data/datasets/snapshots/midlarge-liquid-long-v1 """ from __future__ import annotations import argparse import statistics from pathlib import Path from typing import Any import pyarrow.parquet as pq def _load_rows(snapshot_dir: Path, split: str) -> list[dict[str, Any]]: parquet_path = snapshot_dir / f"{split}.parquet" table = pq.read_table(parquet_path) return table.to_pylist() def _safe_float(raw: Any) -> float | None: try: return float(raw) except (TypeError, ValueError): return None def _win_rate(values: list[float]) -> float: if not values: return 0.0 return sum(1 for v in values if v > 0) / len(values) def _pct(value: float) -> str: return f"{value * 100:+.2f}%" def _fmt_pct(value: float) -> str: return f"{value:.1%}" # ─────────────────────────────────────────────────────────────────── # Analysis 1: Directional follow-through # ─────────────────────────────────────────────────────────────────── def analyze_follow_through(mc_rows: list[dict]) -> None: """Decompose follow-through by initial reaction direction.""" print("\n" + "=" * 70) print("1. DIRECTIONAL FOLLOW-THROUGH DECOMPOSITION") print("=" * 70) horizons = [ ("fwd_return_1d", "1d"), ("fwd_return_3d", "3d"), ("fwd_return_5d", "5d"), ("fwd_return_10d", "10d"), ("fwd_return_20d", "20d"), ] groups = { "bullish_reaction (ret > 0)": [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0], "bearish_reaction (ret < 0)": [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) < 0], "flat_reaction (ret == 0)": [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) == 0], } for group_name, rows in groups.items(): if not rows: continue print(f"\n {group_name}: N={len(rows)}") print(f" {'Horizon':<10} {'Mean':>10} {'Median':>10} {'WinRate':>10} {'StdDev':>10}") print(f" {'-'*50}") for col, label in horizons: vals = [float(r[col]) for r in rows if r.get(col) is not None] if not vals: continue mean = statistics.mean(vals) median = statistics.median(vals) wr = _win_rate(vals) sd = statistics.stdev(vals) if len(vals) > 1 else 0.0 print(f" {label:<10} {_pct(mean):>10} {_pct(median):>10} {_fmt_pct(wr):>10} {_pct(sd):>10}") # ─────────────────────────────────────────────────────────────────── # Analysis 2: Return profile # ─────────────────────────────────────────────────────────────────── def analyze_return_profile(mc_rows: list[dict]) -> None: """Forward return statistics at each horizon.""" print("\n" + "=" * 70) print("2. RETURN PROFILE (all management_change)") print("=" * 70) print(f" Total events: {len(mc_rows)}") horizons = [ ("fwd_return_1d", "1d"), ("fwd_return_3d", "3d"), ("fwd_return_5d", "5d"), ("fwd_return_10d", "10d"), ("fwd_return_20d", "20d"), ] print(f"\n {'Horizon':<10} {'Mean':>10} {'Median':>10} {'WinRate':>10} {'P25':>10} {'P75':>10}") print(f" {'-'*60}") for col, label in horizons: vals = sorted([float(r[col]) for r in mc_rows if r.get(col) is not None]) if not vals: continue mean = statistics.mean(vals) median = statistics.median(vals) wr = _win_rate(vals) p25 = vals[len(vals) // 4] p75 = vals[3 * len(vals) // 4] print(f" {label:<10} {_pct(mean):>10} {_pct(median):>10} {_fmt_pct(wr):>10} {_pct(p25):>10} {_pct(p75):>10}") # ─────────────────────────────────────────────────────────────────── # Analysis 3: MAE/MFE profile # ─────────────────────────────────────────────────────────────────── def analyze_mae_mfe(mc_rows: list[dict]) -> None: """Maximum Adverse/Favorable Excursion by horizon.""" print("\n" + "=" * 70) print("3. MAE / MFE PROFILE") print("=" * 70) windows = [("3d", "mae_3d", "mfe_3d"), ("5d", "mae_5d", "mfe_5d"), ("10d", "mae_10d", "mfe_10d"), ("20d", "mae_20d", "mfe_20d")] print(f"\n {'Window':<8} {'MAE_mean':>10} {'MAE_med':>10} {'MFE_mean':>10} {'MFE_med':>10} {'MFE/MAE':>10}") print(f" {'-'*58}") for label, mae_col, mfe_col in windows: mae_vals = [abs(float(r[mae_col])) for r in mc_rows if r.get(mae_col) is not None] mfe_vals = [float(r[mfe_col]) for r in mc_rows if r.get(mfe_col) is not None] if not mae_vals or not mfe_vals: continue mae_mean = statistics.mean(mae_vals) mae_med = statistics.median(mae_vals) mfe_mean = statistics.mean(mfe_vals) mfe_med = statistics.median(mfe_vals) ratio = mfe_mean / mae_mean if mae_mean > 0 else 0 print(f" {label:<8} {_pct(mae_mean):>10} {_pct(mae_med):>10} {_pct(mfe_mean):>10} {_pct(mfe_med):>10} {ratio:>10.2f}") # ─────────────────────────────────────────────────────────────────── # Analysis 4: Filter effect simulation # ─────────────────────────────────────────────────────────────────── def analyze_filter_effects(mc_rows: list[dict]) -> None: """Test how filters affect count and follow-through quality.""" print("\n" + "=" * 70) print("4. FILTER EFFECT SIMULATION") print("=" * 70) filters = [ ("No filter (baseline)", lambda r: True), ("reaction_day_return > 0 (bullish)", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0), ("reaction_day_return > 0.02", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0.02), ("reaction_day_return > 0.03", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0.03), ("reaction_day_return > 0.05", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) > 0.05), ("document_quality >= 0.5", lambda r: (_safe_float(r.get("document_quality_score")) or 0) >= 0.5), ("volume_ratio >= 1.5x", lambda r: (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.5), ("volume_ratio >= 1.2x", lambda r: (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.2), ("market_cap >= $2B", lambda r: (_safe_float(r.get("market_cap_proxy")) or 0) >= 2e9), ("market_cap >= $5B", lambda r: (_safe_float(r.get("market_cap_proxy")) or 0) >= 5e9), ("close_location >= 0.5", lambda r: (_safe_float(r.get("close_location")) or 0) >= 0.5), ("close_location >= 0.6", lambda r: (_safe_float(r.get("close_location")) or 0) >= 0.6), # Combined filters ("bullish + doc>=0.5", lambda r: ( (_safe_float(r.get("reaction_day_return")) or 0) > 0 and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 )), ("bullish + vol>=1.2", lambda r: ( (_safe_float(r.get("reaction_day_return")) or 0) > 0 and (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.2 )), ("bullish + close>=0.5", lambda r: ( (_safe_float(r.get("reaction_day_return")) or 0) > 0 and (_safe_float(r.get("close_location")) or 0) >= 0.5 )), ("bullish + doc>=0.5 + close>=0.5", lambda r: ( (_safe_float(r.get("reaction_day_return")) or 0) > 0 and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 and (_safe_float(r.get("close_location")) or 0) >= 0.5 )), ("bullish + doc>=0.5 + vol>=1.2", lambda r: ( (_safe_float(r.get("reaction_day_return")) or 0) > 0 and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 and (_safe_float(r.get("volume_ratio_20d")) or 0) >= 1.2 )), ("ret>0.03 + doc>=0.5", lambda r: ( (_safe_float(r.get("reaction_day_return")) or 0) > 0.03 and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5 )), ("ret>0.03 + close>=0.5", lambda r: ( (_safe_float(r.get("reaction_day_return")) or 0) > 0.03 and (_safe_float(r.get("close_location")) or 0) >= 0.5 )), ] horizons = ["fwd_return_5d", "fwd_return_10d", "fwd_return_20d"] h_labels = ["5d", "10d", "20d"] header = f" {'Filter':<42} {'N':>5}" for h in h_labels: header += f" {'WR_' + h:>8} {'Mean_' + h:>10}" header += f" {'AnnFreq':>8}" print(f"\n{header}") print(f" {'-' * (len(header) - 2)}") # Compute date range for annualization dates = sorted(set(r.get("event_date", "") for r in mc_rows if r.get("event_date"))) if len(dates) >= 2: from datetime import datetime d0 = datetime.strptime(str(dates[0])[:10], "%Y-%m-%d") d1 = datetime.strptime(str(dates[-1])[:10], "%Y-%m-%d") years = max((d1 - d0).days / 365.25, 0.5) else: years = 1.0 for name, fn in filters: filtered = [r for r in mc_rows if fn(r)] n = len(filtered) ann_freq = n / years row_str = f" {name:<42} {n:>5}" for h_col in horizons: vals = [float(r[h_col]) for r in filtered if r.get(h_col) is not None] if vals: wr = _win_rate(vals) mean = statistics.mean(vals) row_str += f" {_fmt_pct(wr):>8} {_pct(mean):>10}" else: row_str += f" {'N/A':>8} {'N/A':>10}" row_str += f" {ann_freq:>7.1f}" print(row_str) # ─────────────────────────────────────────────────────────────────── # Analysis 5: Temporal distribution # ─────────────────────────────────────────────────────────────────── def analyze_temporal_distribution(mc_rows: list[dict]) -> None: """Monthly and quarterly frequency, earnings season overlap.""" print("\n" + "=" * 70) print("5. TEMPORAL DISTRIBUTION") print("=" * 70) from datetime import datetime months: dict[str, int] = {} quarters: dict[str, int] = {} earnings_months = {1, 2, 4, 5, 7, 8, 10, 11} # typical earnings season months in_season = 0 out_season = 0 for r in mc_rows: ed = str(r.get("event_date", ""))[:10] if len(ed) < 7: continue try: dt = datetime.strptime(ed, "%Y-%m-%d") except ValueError: continue ym = f"{dt.year}-{dt.month:02d}" yq = f"{dt.year}-Q{(dt.month - 1) // 3 + 1}" months[ym] = months.get(ym, 0) + 1 quarters[yq] = quarters.get(yq, 0) + 1 if dt.month in earnings_months: in_season += 1 else: out_season += 1 total = in_season + out_season print(f"\n Earnings season months (Jan/Feb/Apr/May/Jul/Aug/Oct/Nov): {in_season} ({in_season/total:.0%})") print(f" Non-earnings months (Mar/Jun/Sep/Dec): {out_season} ({out_season/total:.0%})") print(f" --> Temporal diversification: {'GOOD' if out_season / total >= 0.20 else 'POOR'}") print(f"\n Quarterly distribution:") for q in sorted(quarters.keys()): bar = "#" * quarters[q] print(f" {q}: {quarters[q]:>3} {bar}") # Monthly avg if months: avg_per_month = statistics.mean(months.values()) print(f"\n Average events per month: {avg_per_month:.1f}") print(f" Total months with events: {len(months)}/{len(months)}") print(f" Min/Max per month: {min(months.values())}/{max(months.values())}") # ─────────────────────────────────────────────────────────────────── # Analysis 6: Comparison vs earnings_release baseline # ─────────────────────────────────────────────────────────────────── def analyze_vs_earnings(mc_rows: list[dict], all_rows: list[dict]) -> None: """Compare MC follow-through to earnings_release baseline.""" print("\n" + "=" * 70) print("6. MANAGEMENT_CHANGE vs EARNINGS_RELEASE COMPARISON") print("=" * 70) er_rows = [r for r in all_rows if r.get("event_type") == "earnings_release"] # Bullish subsets mc_bull = [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0] er_bull = [r for r in er_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0] horizons = [ ("fwd_return_5d", "5d"), ("fwd_return_10d", "10d"), ("fwd_return_20d", "20d"), ] print(f"\n ALL events:") print(f" {'Metric':<20} {'MC (N={len(mc_rows)})':>20} {'ER (N={len(er_rows)})':>20}") print(f" {'-'*60}") for col, label in horizons: mc_vals = [float(r[col]) for r in mc_rows if r.get(col) is not None] er_vals = [float(r[col]) for r in er_rows if r.get(col) is not None] mc_wr = _win_rate(mc_vals) if mc_vals else 0 er_wr = _win_rate(er_vals) if er_vals else 0 mc_mean = statistics.mean(mc_vals) if mc_vals else 0 er_mean = statistics.mean(er_vals) if er_vals else 0 print(f" {label + ' WR':<20} {_fmt_pct(mc_wr):>20} {_fmt_pct(er_wr):>20}") print(f" {label + ' Mean':<20} {_pct(mc_mean):>20} {_pct(er_mean):>20}") print(f"\n BULLISH reaction only (ret > 0):") print(f" {'Metric':<20} {'MC (N={len(mc_bull)})':>20} {'ER (N={len(er_bull)})':>20}") print(f" {'-'*60}") for col, label in horizons: mc_vals = [float(r[col]) for r in mc_bull if r.get(col) is not None] er_vals = [float(r[col]) for r in er_bull if r.get(col) is not None] mc_wr = _win_rate(mc_vals) if mc_vals else 0 er_wr = _win_rate(er_vals) if er_vals else 0 mc_mean = statistics.mean(mc_vals) if mc_vals else 0 er_mean = statistics.mean(er_vals) if er_vals else 0 print(f" {label + ' WR':<20} {_fmt_pct(mc_wr):>20} {_fmt_pct(er_wr):>20}") print(f" {label + ' Mean':<20} {_pct(mc_mean):>20} {_pct(er_mean):>20}") # ─────────────────────────────────────────────────────────────────── # Analysis 7: Reaction magnitude buckets # ─────────────────────────────────────────────────────────────────── def analyze_reaction_buckets(mc_rows: list[dict]) -> None: """Break down follow-through by reaction magnitude bucket.""" print("\n" + "=" * 70) print("7. FOLLOW-THROUGH BY REACTION MAGNITUDE BUCKET") print("=" * 70) buckets = [ ("ret < -5%", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) < -0.05), ("-5% <= ret < -2%", lambda r: -0.05 <= (_safe_float(r.get("reaction_day_return")) or 0) < -0.02), ("-2% <= ret < 0%", lambda r: -0.02 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0), ("0% <= ret < 2%", lambda r: 0 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0.02), ("2% <= ret < 5%", lambda r: 0.02 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0.05), ("5% <= ret < 10%", lambda r: 0.05 <= (_safe_float(r.get("reaction_day_return")) or 0) < 0.10), ("ret >= 10%", lambda r: (_safe_float(r.get("reaction_day_return")) or 0) >= 0.10), ] print(f"\n {'Bucket':<22} {'N':>5} {'5d_WR':>8} {'5d_Mean':>10} {'10d_WR':>8} {'10d_Mean':>10} {'20d_WR':>8} {'20d_Mean':>10}") print(f" {'-'*90}") for name, fn in buckets: filtered = [r for r in mc_rows if fn(r)] n = len(filtered) if n == 0: continue parts = f" {name:<22} {n:>5}" for col in ["fwd_return_5d", "fwd_return_10d", "fwd_return_20d"]: vals = [float(r[col]) for r in filtered if r.get(col) is not None] if vals: wr = _win_rate(vals) mean = statistics.mean(vals) parts += f" {_fmt_pct(wr):>8} {_pct(mean):>10}" else: parts += f" {'N/A':>8} {'N/A':>10}" print(parts) # ─────────────────────────────────────────────────────────────────── # Judgment summary # ─────────────────────────────────────────────────────────────────── def print_judgment(mc_rows: list[dict], years: float) -> None: """Evaluate against Phase 1 pass/fail criteria.""" print("\n" + "=" * 70) print("PHASE 1 JUDGMENT CRITERIA") print("=" * 70) # Criterion 1: Bullish reaction -> 5d forward return positive >= 55% bullish = [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0] bull_5d = [float(r["fwd_return_5d"]) for r in bullish if r.get("fwd_return_5d") is not None] bull_wr = _win_rate(bull_5d) if bull_5d else 0 # Criterion 2: Filtered events >= 30/year # Use "bullish + doc>=0.5" as a reasonable filter combo filtered = [r for r in mc_rows if (_safe_float(r.get("reaction_day_return")) or 0) > 0 and (_safe_float(r.get("document_quality_score")) or 0) >= 0.5] ann_freq = len(filtered) / years # Criterion 3: Temporal diversification (non-earnings-season >= 20%) from datetime import datetime earnings_months = {1, 2, 4, 5, 7, 8, 10, 11} out_season = 0 total = 0 for r in mc_rows: ed = str(r.get("event_date", ""))[:10] try: dt = datetime.strptime(ed, "%Y-%m-%d") total += 1 if dt.month not in earnings_months: out_season += 1 except ValueError: pass temporal_div = out_season / total if total > 0 else 0 # Criterion 4: Win rate >= 52% (all bullish MC at 5d) overall_wr = bull_wr # same as criterion 1 # Print results c1_pass = bull_wr >= 0.55 c2_pass = ann_freq >= 30 c3_pass = temporal_div >= 0.20 c4_pass = overall_wr >= 0.52 all_pass = c1_pass and c2_pass and c3_pass and c4_pass def _status(ok: bool) -> str: return "PASS" if ok else "FAIL" print(f"\n 1. Bullish reaction -> 5d WR >= 55%: {_fmt_pct(bull_wr):>8} [{_status(c1_pass)}]") print(f" 2. Filtered events >= 30/year: {ann_freq:>7.1f} [{_status(c2_pass)}]") print(f" 3. Temporal diversification >= 20%: {_fmt_pct(temporal_div):>8} [{_status(c3_pass)}]") print(f" 4. Win rate >= 52%: {_fmt_pct(overall_wr):>8} [{_status(c4_pass)}]") print(f"\n OVERALL: {'>>> PROCEED TO PHASE 2 <<<' if all_pass else '>>> DOES NOT PASS — review filter combos above <<<'}") # Also check best filter combo that might pass if not all_pass: print(f"\n NOTE: Check Section 4 filter combos for alternative thresholds that may pass.") # ─────────────────────────────────────────────────────────────────── # Main # ─────────────────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser(description="Management Change Alpha Analysis") parser.add_argument("--snapshot-dir", type=str, default="data/datasets/snapshots/midlarge-liquid-long-v1") parser.add_argument("--split", type=str, default="train", help="Which split to analyze (default: train)") args = parser.parse_args() snapshot_dir = Path(args.snapshot_dir) print(f"Loading {args.split} from {snapshot_dir} ...") all_rows = _load_rows(snapshot_dir, args.split) mc_rows = [r for r in all_rows if r.get("event_type") == "management_change"] print(f"Total events: {len(all_rows)}") print(f"management_change events: {len(mc_rows)}") if not mc_rows: print("No management_change events found. Exiting.") return # Compute years for annualization from datetime import datetime dates = sorted(set(str(r.get("event_date", ""))[:10] for r in mc_rows)) dates = [d for d in dates if len(d) >= 10] if len(dates) >= 2: d0 = datetime.strptime(dates[0], "%Y-%m-%d") d1 = datetime.strptime(dates[-1], "%Y-%m-%d") years = max((d1 - d0).days / 365.25, 0.5) else: years = 1.0 print(f"Date range: {dates[0]} to {dates[-1]} ({years:.1f} years)") analyze_follow_through(mc_rows) analyze_return_profile(mc_rows) analyze_mae_mfe(mc_rows) analyze_filter_effects(mc_rows) analyze_temporal_distribution(mc_rows) analyze_vs_earnings(mc_rows, all_rows) analyze_reaction_buckets(mc_rows) print_judgment(mc_rows, years) if __name__ == "__main__": main()