# Builds section_callwall.html (anchor #callwall) for the intraday-recoveries page. import pandas as pd,numpy as np,json,datetime as dt,html C='/workspace/research/flow-forensics-2026-10/callwall'; P='callwall/' now=dt.datetime.now(dt.timezone(dt.timedelta(hours=5,minutes=30))).strftime('%Y-%m-%d %H:%M IST') def tb(d,nd=1): return d.round(nd).to_html(index=False,border=0,na_rep='–') S=pd.read_csv(f'{C}/results/hit_stats.csv'); RU=json.load(open(f'{C}/results/fixed_rule.json')) RB=pd.read_csv(f'{C}/results/rule_vs_bench.csv'); BY=pd.read_csv(f'{C}/results/rule_breakdowns.csv'); A=pd.read_csv(f'{C}/results/rule_trades.csv',parse_dates=['date']) E=pd.read_pickle(f'{C}/results/events.pkl'); T=pd.read_pickle(f'{C}/results/trades_all.pkl'); X=pd.read_csv(f'{C}/results/excursions.csv'); G=pd.read_csv(f'{C}/results/train_grid.csv') gaps=pd.read_csv(f'{C}/results/data_gaps.csv') keyv=['a_plain','b_X1','b_X2','b_X3','c_X1_Y25','c_X2_Y10','c_X2_Y25','c_X2_Y50','d_X2_Y25_B3'] def hit_table(side,ok): k=S[(S.side==side)&(S.outcome==ok)&(S.variant.isin(keyv))] rows=[] for (wd,v),g in k.groupby(['wall','variant'],sort=False): tr=g[g.split=='train'].iloc[0]; te=g[g.split=='test'].iloc[0]; tx=g[g.split=='test_ex_src'].iloc[0] rows.append({'wall':wd,'variant':v,'train n':tr.n,'train hit%':tr.rate,'train random%':tr.rand_rate,'test n':te.n,'test hit%':te.rate,'test random%':te.rand_rate,'test plain-cross%':te.plain_rate, 'p binom vs random':te.p_binom,'p random draws':te.p_rand_emp,'p Fisher vs plain':te.p_fisher_vs_plain,'BH q':te.q_bh,'test excl 23/26-Oct hit%':tx.rate}) d=pd.DataFrame(rows); d['o']=d.variant.map({v:i for i,v in enumerate(keyv)}); return d.sort_values(['wall','o']).drop(columns='o') def fmt(d): d=d.copy() for c in d.columns: if c.startswith('p ') or c=='BH q': d[c]=d[c].map(lambda x:'–' if x!=x else f'{x:.3f}') return d tops=S[S.split=='test'].sort_values('p_binom').head(5)[['side','wall','variant','outcome','n','hits','rate','rand_rate','p_binom','q_bh']] rc=RU['call']; rp=RU['put'] RBt=RB.rename(columns={'bench':'what','mean_pts':'net pts/trade','win':'win %','rs_lot_total':'₹/lot total','rs_lot_mean':'₹/lot per trade','mean_ex_srcdays':'test pts excl 23/26-Oct'}) RBt['what']=RBt.what.map({'rule':'fixed rule','plain':'plain cross (a), same option+exit','random':'random same-minute entry (20× per trade)'}) yr=BY[BY.by=='year'].drop(columns='by').rename(columns={'bucket':'year','mean_pts':'net pts/trade','win':'win %','rs_lot':'₹/lot total'}) mo=BY[BY.by=='month'].drop(columns='by'); mo=mo.pivot_table(index='bucket',columns='side',values=['n','mean_pts','rs_lot']).round(1); mo.columns=[f'{b} {a}' for a,b in mo.columns]; mo=mo.reset_index().rename(columns={'bucket':'month'}) gm=A.groupby(['side','period','gamma']).agg(n=('pnl','size'),pts=('pnl','mean'),win=('pnl',lambda x:(x>0).mean()*100),rs_lot=('pnl_rs_lot','sum')).reset_index() A['MonTue']=np.where(A.wd.isin([0,1]),'Mon/Tue','Wed–Fri') mt=A.groupby(['side','period','MonTue']).agg(n=('pnl','size'),pts=('pnl','mean'),win=('pnl',lambda x:(x>0).mean()*100)).reset_index() wd=A.groupby(['side','date']).pnl_rs_lot.sum().reset_index().sort_values('pnl_rs_lot').groupby('side').head(3); wd['date']=wd.date.dt.strftime('%a %d-%b-%Y') wtr=A.sort_values('pnl').groupby('side').head(1)[['side','date','hm_sig','K','fill','pnl','pnl_rs_lot']]; wtr['date']=wtr.date.dt.strftime('%d-%b-%Y') xs=X[X.variant.isin(['a_plain','b_X2','c_X2_Y25','d_X2_Y25_B3'])] # today t=E[E.date=='2026-10-26']; tr=T.merge(E[['date','side','wall','K','hm']],left_on='ev',right_index=True); tr=tr[(tr.date=='2026-10-26')&(tr.instr=='wall')] trows=[] for r in t.itertuples(): for X_ in (0,1,2,3): s=r.i if X_==0 else getattr(r,f's_X{X_}') sig=f'{(555+s)//60:02d}:{(555+s)%60:02d}' if s>=0 else 'did not fire' opp_ok=r.opp_oi_chg30; bas=getattr(r,f'basis_chg5_X{X_}') q=tr[(tr.ev==r.Index)&(tr.X==X_)] e4=q[q.exit=='E4_prem20_scale']; e2=q[q.exit=='E2_spotstopK30_t150'] trows.append({'side':r.side,'wall':r.wall,'strike':int(r.K),'cross':r.hm,'stage':'(a) plain cross' if X_==0 else f'(b) OI drop ≥{X_}%','signal':sig, 'own-OI chg 1/2/3 min %':f'{r.own_drop1:.1f} / {r.own_drop2:.1f} / {r.own_drop3:.1f}','opposite OI 30m %':round(opp_ok,1),'basis chg 5m':round(bas,1) if bas==bas else '–', '+50 before −30 (30m)':getattr(r,f'p50_s30_w30_X{X_}',np.nan),'+100 before −30 (60m)':getattr(r,f'p100_s30_w60_X{X_}',np.nan),'MFE60':getattr(r,f'mfe60_X{X_}',np.nan),'MAE60':getattr(r,f'mae60_X{X_}',np.nan), 'wall opt fill':e4.fill.iloc[0] if len(e4) else np.nan,'net pts E4 (prem stop+scale)':e4.pnl.iloc[0] if len(e4) else np.nan,'net pts E2 (spot K−30, +150)':e2.pnl.iloc[0] if len(e2) else np.nan}) TD=pd.DataFrame(trows) TD=TD.drop_duplicates(subset=[c for c in TD.columns if c not in('stage',)],keep='first') TD.to_csv(f'{C}/results/today_events.csv',index=False) nE=len(E); nd=E.date.nunique() ct=RB[(RB.side=='call')&(RB.bench=='rule')&(RB.period=='test')].iloc[0]; cp=RB[(RB.side=='call')&(RB.bench=='plain')&(RB.period=='test')].iloc[0]; cr=RB[(RB.side=='call')&(RB.bench=='random')&(RB.period=='test')].iloc[0] pt=RB[(RB.side=='put')&(RB.bench=='rule')&(RB.period=='test')].iloc[0] H=f"""

8. Call-wall cross squeeze trigger (tested 26-Oct-2026, asked 16:20 IST)

Built {now}. Question: on 26-Oct the 22,500 call writers added OI while spot was below 22,500, then covered as soon as spot crossed it (CE22500 OI 18.2M→11.2M by 10:08, Nifty 22,505→22,699) while put writers built a floor at the same strike. Is “spot crosses the heaviest call strike + call OI drops right after” a tradeable trigger for an option buyer? Sample: every session with nearest-weekly 1-min option OI, 01-Oct-2024 → 26-Oct-2026 ({len(set(E.date))} days with a cross; {nE} crosses). Train Oct-2024–Dec-2025, test 2026 (out of sample). Read-only, no orders, research only.

Plain answer: no edge. Today was a textbook case and it paid, but across two years the same trigger is a coin-flip that loses money after costs.
  1. Crossing the call wall is not special. In 2026, after spot closes above the heaviest call strike, it reaches +50 before −30 within 30 min {S[(S.side=='call')&(S.wall=='dyn')&(S.variant=='a_plain')&(S.split=='test')&(S.outcome=='p50_s30_w30')].rate.iloc[0]:.0f}% of the time, vs {S[(S.side=='call')&(S.wall=='dyn')&(S.variant=='a_plain')&(S.split=='test')&(S.outcome=='p50_s30_w30')].rand_rate.iloc[0]:.0f}% for random entries at the same minute on other days. +100 before −30 within 60 min: {S[(S.side=='call')&(S.wall=='dyn')&(S.variant=='a_plain')&(S.split=='test')&(S.outcome=='p100_s30_w60')].rate.iloc[0]:.1f}% vs {S[(S.side=='call')&(S.wall=='dyn')&(S.variant=='a_plain')&(S.split=='test')&(S.outcome=='p100_s30_w60')].rand_rate.iloc[0]:.1f}% random.
  2. Adding the call-OI drop, the put-writer floor and basis widening does not help out of sample. Across 88 variants × 12 outcomes, no 2026 result survives the Benjamini–Hochberg correction (smallest test q = {S[S.split=='test'].q_bh.min():.2f}). The few nominal p<0.05 results are variants with basis widening hitting less often than random (0 of 12–14), i.e. the opposite of the idea. In train (2024–25) the plain cross looked mildly better than random (+50/−30 in 30 min: 26.8% vs 20.1%, p=0.044) — that did not repeat in 2026 (16.0% vs 19.3%).
  3. Trade (rule fixed on train before looking at 2026): {html.escape(rc['variant'])} on the dynamic wall — buy the crossed wall-strike CE at the next minute’s open +3 slippage, premium stop −20, sell 40% at +20, 30% at +40, trail the rest 20 below the peak, 60-min cap. Train: {rc['n']:.0f} trades, {rc['mean_pts']:+.1f} pts/trade (cherry-picked out of {rc['n_eligible']} eligible combos). 2026: {ct.n:.0f} trades, {ct.mean_pts:+.1f} pts/trade, win {ct.win:.0f}%, ₹{ct.rs_lot_total:,.0f}/lot total — no better than the plain cross ({cp.mean_pts:+.1f}) or random same-minute entries ({cr.mean_pts:+.1f}). Mean significantly below zero (t-test p=0.003).
  4. Puts (mirror: spot crosses below heaviest put strike + put OI drops): no variant was positive even on train; best train combo was the plain cross with spot stop K+30 / target −150 at {rp['mean_pts']:+.1f} pts/trade; 2026: {pt.n:.0f} trades, {pt.mean_pts:+.1f} pts/trade, win {pt.win:.0f}%, ₹{pt.rs_lot_total:,.0f}/lot. Fail.
  5. Expiry / pre-expiry days (gamma): worse, not better. Call rule 2026: expiry day {gm[(gm.side=='call')&(gm.period=='test')&(gm.gamma=='expiry day')].pts.iloc[0]:+.1f} pts (n={gm[(gm.side=='call')&(gm.period=='test')&(gm.gamma=='expiry day')].n.iloc[0]}), pre-expiry {gm[(gm.side=='call')&(gm.period=='test')&(gm.gamma=='pre-expiry')].pts.iloc[0]:+.1f} (n={gm[(gm.side=='call')&(gm.period=='test')&(gm.gamma=='pre-expiry')].n.iloc[0]}), other {gm[(gm.side=='call')&(gm.period=='test')&(gm.gamma=='other')].pts.iloc[0]:+.1f} (n={gm[(gm.side=='call')&(gm.period=='test')&(gm.gamma=='other')].n.iloc[0]}).
  6. Today: the dynamic call wall was 22,500; spot crossed 09:52 (22,505); CE22500 OI fell 8.5% by 09:53 (−17% by 09:55) and PE22500 OI was +28.8% over the prior 30 min → plain cross, (b) X=1/2/3 and (c) Y=10/25 all fired; (d) did not (basis +2.0 in the 5 min to 09:53, below +3; the 60→88 widening came after). The fixed rule bought the 22500 CE at the 09:54 open (fill 181.8 incl. slippage): +37.1 pts net (≈₹2,410/lot of 65), out on the premium trail; a spot-target exit (+150) would have made +127.6. Spot reached +100 before −30 within 60 min. The put-side crosses at 09:17 (22,500) and 09:25 (22,400) had no put-OI drop (put writers were adding) so (b)–(d) did not fire; the put rule (plain cross) bought PE22500 at 09:18 and PE22400 at 09:26 and lost −44.9 and −27.2 pts net. One good day does not change the 2-year record — 23/26-Oct-excluded test is {ct.mean_ex_srcdays:+.1f} pts/trade.
Bottom line: what you saw today — writers adding below the strike, covering above it — is visible on many days, but most of the time the cross stalls within 30–40 pts (median 60-min favourable move ≈33 pts, adverse ≈27–38 pts for every variant), which an ATM buyer cannot overcome after slippage and theta. Not a rule to trade.

Definitions (fixed before results)

Hit rates — calls (spot reaches +50 before −30 within 30 min)

{tb(fmt(hit_table('call','p50_s30_w30')),1)}

Hit rates — calls (+100 before −30 within 60 min)

{tb(fmt(hit_table('call','p100_s30_w60')),1)}

Hit rates — puts (spot falls 50 before rising 30, within 30 min)

{tb(fmt(hit_table('put','p50_s30_w30')),1)}

Hit rates — puts (−100 before +30 within 60 min)

{tb(fmt(hit_table('put','p100_s30_w60')),1)}

All 88 variants × 12 outcomes × 3 periods: hit_stats.csv. Five smallest 2026 p-values (none survive BH):

{tb(tops,3)}

Max favourable / adverse excursion in 60 min (spot pts)

{tb(xs,1)}

Buyer trades after costs: fixed rules vs benchmarks

Call rule: dynamic wall, X={rc['X']:.0f}% CE-OI drop within 3 min, PE OI ≥+25% in prior 30 min, buy wall-strike CE, exit {rc['exit']}. Put rule: dynamic put wall, plain cross, buy wall-strike PE, exit {rp['exit']}.

{tb(RBt,2)}

By year

{tb(yr,2)}

By month (n, net pts/trade, ₹/lot)

{tb(mo,1)}

Expiry-day / pre-expiry (gamma) and Mon/Tue

Weekly Nifty expiry was Thursday until Aug-2025, Tuesday from Sep-2025, so “Mon/Tue” = pre-expiry/expiry only for the recent period; the gamma buckets use trading days to expiry.

{tb(gm,2)}{tb(mt,2)}

Worst days (₹/lot) and worst single trades

{tb(wd,0)}{tb(wtr,1)}

Today, 26-Oct-2026

{tb(TD,1)}

Identical rows for X=1/2/3 collapsed (all fired at 09:53). (c) with Y=10/25 fired today (put-OI +28.8%); Y=50 and (d) did not. Fixed 09:20 call wall today was not crossed.

Data gaps and caveats

Files: callwall/results/ (events, hit_stats, excursions, train_grid, fixed_rule.json, rule_trades, rule_breakdowns, rule_vs_bench) · callwall/scripts/.

""" open(f'{C}/section_callwall.html','w').write(H); print(len(H))