"""Crown-play policy sweep — pre-registered 2026-09-09 (studies/2026-09-09-crown-policies-prereg.md).""" import os, math, warnings, numpy as np, pandas as pd warnings.filterwarnings("ignore") FP="/fp-data"; CACHE="data" SLIP=0.25; RR=1.5; ATR_MULT=2.0; STOP_FLOOR=12.0; CAP_MIN=60; WIN_START=575; WIN_END=720; PT=20.0 GAP_LO=-0.033; GAP_HI=0.157; ON_EDGES=[0.422,0.55,0.687,0.9] def load_nq(): p=f"{CACHE}/NQ_1m_et.parquet" if not os.path.exists(p): raise SystemExit("run open_factors.py first (parquet cache)") d=pd.read_parquet(p); return d def quad_dates(): out=set() for y in range(2010,2028): for m in (3,6,9,12): d=pd.Timestamp(year=y,month=m,day=1); fr=[x for x in pd.date_range(d,d+pd.Timedelta(days=27)) if x.weekday()==4]; out.add(fr[2].date()) return out QUAD=quad_dates() def value_area(rth): px=((rth["open"].values+rth["close"].values)/2/5).round()*5; vol=rth["volume"].values prof=pd.Series(vol).groupby(px).sum().sort_index() if prof.empty: return (np.nan,)*3 idx=list(prof.index); poc=prof.idxmax(); total=prof.sum(); lo=hi=idx.index(poc); acc=prof.iloc[lo] while acc<0.7*total and (lo>0 or hi0 else -1 if up>=dn: hi+=1; acc+=up else: lo-=1; acc+=dn return poc, idx[hi], idx[lo] def five_min(rth): b=(rth["min"].values//5)*5 g=rth.groupby(b); o=g["open"].first(); h=g["high"].max(); l=g["low"].min(); c=g["close"].last(); end=g["min"].max() return pd.DataFrame({"m":o.index,"o":o.values,"h":h.values,"l":l.values,"c":c.values,"endmin":end.values+1}) def atr3(f5,i): if i<3: return np.nan tr=[max(f5.h[k]-f5.l[k], abs(f5.h[k]-f5.c[k-1]), abs(f5.l[k]-f5.c[k-1])) for k in range(i-2,i+1)] return float(np.mean(tr)) def walk(rth, sig_min, side, f5, i5): """fill next 1m open after the signal bar closes; walk 1m bars.""" stop_d=max(STOP_FLOOR, ATR_MULT*atr3(f5,i5)) nxt=rth[rth["min"]>=sig_min] if nxt.empty: return None entry=nxt.iloc[0]["open"]+(SLIP if side>0 else -SLIP); fill_min=int(nxt.iloc[0]["min"]) stop=entry-side*stop_d; target=entry+side*RR*stop_d for _,b in nxt.iterrows(): m=int(b["min"]) hit_stop = b["low"]<=stop if side>0 else b["high"]>=stop hit_tgt = b["high"]>=target if side>0 else b["low"]<=target if hit_stop: return dict(r=-1.0-SLIP/stop_d, res="loss", pts=side*(stop-entry)-SLIP, fill=fill_min, exit=m) if hit_tgt: return dict(r=RR, res="win", pts=side*(target-entry), fill=fill_min, exit=m) if m-fill_min>=CAP_MIN or m>=959: pts=side*(b["close"]-entry)-SLIP; return dict(r=pts/stop_d, res="timeout", pts=pts, fill=fill_min, exit=m) b=nxt.iloc[-1]; pts=side*(b["close"]-entry)-SLIP; return dict(r=pts/stop_d, res="timeout", pts=pts, fill=fill_min, exit=int(b["min"])) def first_signal(f5, cond): """cond(i) -> side or 0, evaluated on 5m bars whose close falls in the trigger window.""" for i in range(len(f5)): endmin=f5.endmin[i] if endminWIN_END: continue s=cond(i) if s: return i, s return None, 0 def run(): d=load_nq(); rows=[] sess=list(d.groupby("sday")) prior=None; rth_ranges=[]; prev_day=None for sday,g in sess: rth=g[(g["min"]>=570)&(g["min"]<960)].reset_index(drop=True); on=g[(g["min"]>=1080)|(g["min"]<570)] if len(rth)<300: continue rng=rth["high"].max()-rth["low"].min() ok = prior is not None and prev_day is not None and (sday-prev_day).days<=4 and len(rth_ranges)>=20 and sday not in QUAD and sday>=pd.Timestamp("2012-01-03").date() and len(on)>=60 if ok: atr20=float(np.mean(rth_ranges[-20:])); pc,vah,val,poc=prior o=rth.iloc[0]["open"]; gap=(o-pc)/atr20; onh=on["high"].max(); onl=on["low"].min(); onq=sum(1 for e in ON_EDGES if (onh-onl)/atr20>e); onlast=on.iloc[-1]["close"] accept = 1 if onlast>vah else (-1 if onlastonh else (-1 if c[i]=3 else (None,0) # P2 contained fade: prior 5m outside VA, this close back inside def fade(i): if i==0: return 0 if c[i-1]>vah and val<=c[i]<=vah: return -1 if c[i-1]pc else 0) elif gap>=GAP_HI: sigs["P3_gap_fade"]=first_signal(f5,lambda i: -1 if c[i]0 and c[i]>vah and any(c[k]0 and c[i]val for k in range(0,i))) else 0 return 0 sigs["P4_value_cont"]=first_signal(f5,cont) if accept!=0 else (None,0) # P6 ORB (15m) sigs["P6_orb15"]=first_signal(f5,lambda i: (1 if (f5.endmin[i]>585 and c[i]>orb_h) else (-1 if (f5.endmin[i]>585 and c[i]=3 else (sigs["P2_contained_fade"] if onq<=1 else (None,0)) for pol,(i5,side) in sigs.items(): elig = not (pol in("P1_expansion_break","P7_regime") and onq<3 and not(pol=="P7_regime" and onq<=1)) if pol=="P1_expansion_break": elig=onq>=3 if pol=="P2_contained_fade": elig=onq<=1 if pol=="P3_gap_fade": elig=gap<=GAP_LO or gap>=GAP_HI if pol=="P4_value_cont": elig=accept!=0 if pol=="P7_regime": elig=onq!=2 if pol in("P5_on_break","P6_orb15"): elig=True w=None if i5 is not None and side: w=walk(rth, int(f5.endmin[i5]), side, f5, i5) rows.append(dict(date=sday, pol=pol, elig=elig, trig=w is not None, side=side, onq=onq, gap=round(gap,3), **(w or {}))) rth_ranges.append(rng); prior=(rth.iloc[-1]["close"],)+tuple(v for v in (value_area(rth)[1],value_area(rth)[2],value_area(rth)[0])); prev_day=sday return pd.DataFrame(rows) df=run(); df.to_parquet(f"{CACHE}/crown_policies.parquet") half=pd.Timestamp("2019-01-01").date(); usd=lambda v:f"{'-' if v<0 else '+'}${abs(v):,.0f}" L=[f"# Crown-play policy sweep — results · {pd.Timestamp.now():%Y-%m-%d %H:%M}\n"] L.append(f"Sessions in scope: {df['date'].nunique()} ({df['date'].min()} → {df['date'].max()}). Fill rules per pre-reg (5m close → next 1m open ±{SLIP}pt, stop {ATR_MULT}×ATR(3)/5m floor {STOP_FLOOR}, target {RR}R, {CAP_MIN}-min cap, window 09:35–12:00).\n") L.append("| policy | eligible | triggers | W / L / T | hit% | R/trigger | R/eligible day | $ at 1 NQ | maxDD (R) | H1 R/trig (n) | H2 R/trig (n) | +years/15 | verdict |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|") for pol,g in df.groupby("pol"): e=g[g.elig]; t=g[g.trig]; if not len(t): L.append(f"| {pol} | {len(e)} | 0 | — | — | — | — | — | — | — | — | — | no triggers |"); continue w=(t.res=="win").sum(); l=(t.res=="loss").sum(); to=(t.res=="timeout").sum(); rt=t.r.mean(); rpe=t.r.sum()/max(1,len(e)); dol=t.pts.sum()*PT cum=t.sort_values("date").r.cumsum(); dd=(cum-cum.cummax()).min() h1=t[t.date=half]; yrs=t.groupby(pd.to_datetime(t.date).dt.year).r.mean(); pos=(yrs>0).sum() ok = len(t)>=100 and len(h1)>0 and len(h2)>0 and h1.r.mean()>=0.10 and h2.r.mean()>=0.10 and pos>=10 v="VALIDATED" if ok else ("suggestive" if (rt>=0.05 and len(t)>=100 and (h1.r.mean()>0 and h2.r.mean()>0)) else "refuted") L.append(f"| {pol} | {len(e)} | {len(t)} | {w} / {l} / {to} | {100*w/max(1,w+l):.0f} | {rt:+.3f} | {rpe:+.3f} | {usd(dol)} | {dd:.1f} | {h1.r.mean():+.3f} ({len(h1)}) | {h2.r.mean():+.3f} ({len(h2)}) | {pos}/{len(yrs)} | {v} |") L.append("\n## Per-year R/trigger (P1 P2 P3 P4 P5 P6 P7)\n") yr=df[df.trig].assign(year=lambda x: pd.to_datetime(x.date).dt.year).pivot_table(index="year",columns="pol",values="r",aggfunc="mean").round(3) L.append(yr.to_string()) L.append("\n## Side split (R/trigger, n)\n"); for pol,g in df[df.trig].groupby("pol"): L.append(f"- {pol}: long {g[g.side>0].r.mean():+.3f} ({(g.side>0).sum()}) · short {g[g.side<0].r.mean():+.3f} ({(g.side<0).sum()})") res="\n".join(L); open(f"{FP}/studies/2026-09-09-crown-policies-results.md","w").write(res); print(res)