You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

251 lines
8.8 KiB
Python

"""
V48 Phase 0.5 — Binary gate: does prior-180d 13D/13G event presence predict V46 trade R?
Three binary flags tested:
has_any_13dg : any filing in prior 180d (PIT-safe: filing_date < entry_date)
has_13d_active : any SC 13D / SCHEDULE 13D (active intent, no 13G, no amendments)
has_initial_entry: any row with is_initial_for_owner=True
Gate (pre-committed):
PASS : |ΔavgR| >= 0.30R AND n_minority >= 30 AND |t| >= 1.96
SOFT-PASS : |ΔavgR| >= 0.20R AND n_minority >= 40 AND |t| >= 2.33
FAIL : else → close axis (24th)
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _welch_t(a: list[float], b: list[float]) -> float:
n1, n2 = len(a), len(b)
if n1 < 2 or n2 < 2:
return 0.0
mean1 = sum(a) / n1
mean2 = sum(b) / n2
var1 = sum((x - mean1) ** 2 for x in a) / (n1 - 1)
var2 = sum((x - mean2) ** 2 for x in b) / (n2 - 1)
se2 = var1 / n1 + var2 / n2
if se2 <= 0:
return 0.0
return (mean1 - mean2) / math.sqrt(se2)
def _win_rate(vals: list[float]) -> float:
return sum(1 for v in vals if v > 0) / len(vals) if vals else 0.0
def _load_trades(path: str) -> list[dict]:
with open(path) as f:
data = json.load(f)
return data.get("trades", [])
def _load_parquet(path: str) -> pd.DataFrame:
df = pd.read_parquet(path)
df["filing_date"] = pd.to_datetime(df["filing_date"]).dt.date
return df
# ---------------------------------------------------------------------------
# Feature computation
# ---------------------------------------------------------------------------
def _compute_flags(trades: list[dict], df: pd.DataFrame, window_days: int) -> list[dict]:
# Build per-symbol index of events
sym_events: dict[str, pd.DataFrame] = {}
for sym, grp in df.groupby("symbol"):
sym_events[str(sym)] = grp.reset_index(drop=True)
results = []
for t in trades:
sym = t["ticker"]
entry_str = t["date"]
entry_date = datetime.fromisoformat(entry_str).date() if "T" in entry_str else datetime.strptime(entry_str, "%Y-%m-%d").date()
cutoff = entry_date - timedelta(days=window_days)
r = t["r_multiple_at_exit"]
has_any = 0
has_13d = 0
has_initial = 0
if sym in sym_events:
sub = sym_events[sym]
# PIT-safe: filing_date strictly before entry_date
mask = (sub["filing_date"] < entry_date) & (sub["filing_date"] >= cutoff)
sub_pit = sub[mask]
if len(sub_pit) > 0:
has_any = 1
# Active 13D: form_type contains '13D' but NOT amendment suffix alone
# SC 13D, SCHEDULE 13D — exclude SC 13D/A and SCHEDULE 13D/A
ft = sub_pit["form_type"].str.upper()
is_13d = ft.isin(["SC 13D", "SCHEDULE 13D"])
if is_13d.sum() > 0:
has_13d = 1
if sub_pit["is_initial_for_owner"].sum() > 0:
has_initial = 1
results.append({
"ticker": sym,
"date": entry_str,
"r": r,
"has_any_13dg": has_any,
"has_13d_active": has_13d,
"has_initial_entry": has_initial,
})
return results
# ---------------------------------------------------------------------------
# Gate evaluation
# ---------------------------------------------------------------------------
PASS_DELTA_R = 0.30
PASS_N_MIN = 30
PASS_T = 1.96
SOFT_DELTA_R = 0.20
SOFT_N_MIN = 40
SOFT_T = 2.33
def _evaluate_flag(name: str, rows: list[dict]) -> dict:
pos = [r["r"] for r in rows if r[name] == 1]
neg = [r["r"] for r in rows if r[name] == 0]
n_pos = len(pos)
n_neg = len(neg)
avg_pos = sum(pos) / n_pos if pos else 0.0
avg_neg = sum(neg) / n_neg if neg else 0.0
delta_r = abs(avg_pos - avg_neg)
wr_pos = _win_rate(pos)
wr_neg = _win_rate(neg)
delta_wr = abs(wr_pos - wr_neg) * 100
t_stat = abs(_welch_t(pos, neg))
direction = "+" if avg_pos > avg_neg else "-"
# Minority group is the smaller (has=1 group)
n_minority = n_pos
if (delta_r >= PASS_DELTA_R and n_minority >= PASS_N_MIN and t_stat >= PASS_T):
verdict = "PASS"
elif (delta_r >= SOFT_DELTA_R and n_minority >= SOFT_N_MIN and t_stat >= SOFT_T):
verdict = "SOFT-PASS"
else:
verdict = "FAIL"
return {
"flag": name,
"n_minority": n_minority,
"n_majority": n_neg,
"avg_r_minority": round(avg_pos, 4),
"avg_r_majority": round(avg_neg, 4),
"delta_r": round(delta_r, 4),
"wr_minority_pct": round(wr_pos * 100, 1),
"wr_majority_pct": round(wr_neg * 100, 1),
"delta_wr_pp": round(delta_wr, 1),
"t_stat": round(t_stat, 3),
"direction": direction,
"verdict": verdict,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--v46-run", default="runs/v46_correct_w12_400d.json/intraday_20260422_043215_63f03e64.json")
ap.add_argument("--parquet", default="data/reference/ownership_13d13g_events_pit.parquet")
ap.add_argument("--window-days", type=int, default=180)
ap.add_argument("--out", default=None)
args = ap.parse_args()
os.chdir(ROOT)
trades = _load_trades(args.v46_run)
df = _load_parquet(args.parquet)
print(f"V46 trades: {len(trades)}")
print(f"13D/G parquet rows: {len(df)}, date range: {df['filing_date'].min()}{df['filing_date'].max()}")
# Parquet tail gap warning
max_trade_date = max(datetime.fromisoformat(t["date"]).date() if "T" in t["date"] else datetime.strptime(t["date"], "%Y-%m-%d").date() for t in trades)
parquet_max = df["filing_date"].max()
gap_days = (max_trade_date - parquet_max).days
if gap_days > 14:
print(f" WARN: parquet ends {parquet_max}, last trade {max_trade_date} ({gap_days}d gap) — tail trades may have false-negative flags")
rows = _compute_flags(trades, df, args.window_days)
total = len(rows)
n_any = sum(r["has_any_13dg"] for r in rows)
n_13d = sum(r["has_13d_active"] for r in rows)
n_ini = sum(r["has_initial_entry"] for r in rows)
print(f"\nCoverage (prior {args.window_days}d):")
print(f" has_any_13dg : {n_any}/{total} = {n_any/total*100:.1f}%")
print(f" has_13d_active : {n_13d}/{total} = {n_13d/total*100:.1f}%")
print(f" has_initial_entry: {n_ini}/{total} = {n_ini/total*100:.1f}%")
flags = ["has_any_13dg", "has_13d_active", "has_initial_entry"]
evaluations = [_evaluate_flag(f, rows) for f in flags]
print(f"\n{'Flag':<22} {'n_min':>5} {'avgR_min':>9} {'avgR_maj':>9} {'ΔR':>7} {'ΔWR':>6} {'|t|':>6} {'dir':>4} {'Verdict'}")
print("-" * 90)
for e in evaluations:
print(
f"{e['flag']:<22} {e['n_minority']:>5} {e['avg_r_minority']:>+9.4f} "
f"{e['avg_r_majority']:>+9.4f} {e['delta_r']:>7.4f} {e['delta_wr_pp']:>6.1f}pp "
f"{e['t_stat']:>6.3f} {e['direction']:>4} {e['verdict']}"
)
print(f"\nGates: PASS=|ΔR|≥{PASS_DELTA_R}R & n_min≥{PASS_N_MIN} & |t|≥{PASS_T}")
print(f" SOFT=|ΔR|≥{SOFT_DELTA_R}R & n_min≥{SOFT_N_MIN} & |t|≥{SOFT_T}")
verdicts = [e["verdict"] for e in evaluations]
if "PASS" in verdicts:
axis_verdict = "ADVANCE_TO_PHASE1"
elif "SOFT-PASS" in verdicts:
axis_verdict = "SOFT_ADVANCE_TO_PHASE1"
else:
axis_verdict = "CLOSE_AXIS_24"
print(f"\n>>> PHASE 0.5 VERDICT: {axis_verdict} <<<")
if axis_verdict in ("CLOSE_AXIS_24",):
best = max(evaluations, key=lambda e: e["delta_r"])
print(f" Best flag: {best['flag']} ΔR={best['delta_r']:.4f}R n={best['n_minority']} |t|={best['t_stat']:.3f}")
print(" → V48 13D/G axis closed. Route to post-close decision tree (Option A/B/C).")
result = {
"axis": "V48_activist_13dg",
"phase": "0.5_binary_gate",
"window_days": args.window_days,
"n_trades": total,
"coverage": {"has_any_13dg": n_any, "has_13d_active": n_13d, "has_initial_entry": n_ini},
"evaluations": evaluations,
"verdict": axis_verdict,
}
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
with open(args.out, "w") as f:
json.dump(result, f, indent=2)
print(f"\nSaved → {args.out}")
if __name__ == "__main__":
main()