import pandas as pd,numpy as np,html E_=html.escape R=pd.read_csv('results/lows_summary.csv'); Z=pd.read_csv('results/p2_config_stats.csv'); C=pd.read_csv('results/p2_outcomes_controls.csv') PO=pd.read_csv('results/p2_priceonly_train_test.csv'); Y=pd.read_csv('results/p2_outcomes_by_year.csv'); EV=pd.read_csv('results/p2_events.csv'); T=pd.read_csv('results/p2_option_trades.csv') FINAL={0:'News-linked relief bounce (oil fell on Trump "energy truce" headlines 10:02–10:13) — FAILED', 1:'No heavy buying (fut vol 0.7x); closing-auction lift only', 2:'REAL BUYING: fresh longs (OI +750), IT-led on 6–10x volume — HELD (also next day)', 3:'Morning flush reversal; intraday OI flat (day = short covering); results stocks (TECHM 28x) — held', 4:'Short-covering spike (OI −949, 15 stocks ≥3x) — FAILED, real low came at 13:47', 5:'Heavy two-way volume (Bank Nifty fut 10.6x) but weak bounce — absorption, not buying', 6:'Fresh longs (OI +404, RIL 9x) — TRAPPED, low broken within the hour', 7:'Fresh longs (OI +471, 10 stocks ≥3x) — TRAPPED, new low at 14:47', 8:'Late short covering (OI −721) + closing-auction lift (+161 to official close)', 9:'Gap-down flush; volume normal for 09:35 (1.0x); put writing at 22,550; +122 in 15 min — held', 10:'Mixed (OI flat); slow grind back +89 in 60 min — held', 11:'Mixed (OI flat); HDFCBANK 10.8x; +46 in 30 min — held', 12:'Fresh longs (OI +1,666 indicative), BHARTIARTL 15.9x — TRAPPED, low broken at 12:51', 13:'Fresh longs, small bounce (+32 in 30 min); the big move was the 13:38 burst (+137): no confirmed news catalyst; futures-led spike (8.7x fut vol). Nearest event: FM chaired the 30th FSDC meeting ~12:16–13:28 IST, no market-moving remark found. The 13:55 tax headline was a repost of the 13-Oct story.'} STORY={ 0:"Nifty fell 179 from the 09:15 open-high. At 09:54 futures volume hit 4.4x normal with 6 stocks at ≥3x (BHARTIARTL 3.9x led). It bounced +29 in 15 min as oil eased on Trump's Russia 'energy truce' headlines (10:02–10:13), but futures OI barely moved (+186 contracts) — nobody committed. The bounce failed: −30 at +60 min, and by 15:14 Nifty was 155 below this 'low' (day low 15:10).", 1:"The real day low after a 347-pt fall, at 15:10. There was NO heavy buying: futures volume 0.7x normal. Spot ticked up only +12 by 15:14; the official close was +59 above the low purely via the closing auction. Next morning (13-Oct) opened marginally below this low, then rallied 359 on short covering.", 2:"After a 199-pt slide from 09:48, the 13:15 low came with the heaviest futures volume of the sample (9.4x Nifty, 17.3x Bank Nifty) and fresh longs (OI +750). IT led — INFY 10.1x volume, TCS 6.6x — and heavyweights turned up 0–7 min before the index. +56 in 30 min, +109 max; the low held the rest of the day and the next day. The only low in the sample that matches 'heavy buying at the low' in the strong sense.", 3:"A 163-pt flush in the first 21 minutes; the 09:36 low had 5.2x futures volume but flat OI (+39). BHARTIARTL, SBIN, INFY and TCS turned first (09:26–09:28); HDFCBANK did +15 pts of the bounce and TECHM (results) traded 27.7x. +154 in 30 min, +236 in 60 (more than the whole fall). On the day this was the third short-covering session (futures OI −2.7k).", 4:"Bank results day: a −205 dump in 11 minutes, then the low at 09:29 with 5.1x futures volume, 15 stocks at ≥3x (ICICIBANK 13.1x) and OI −949 = shorts booking profits. +174 in 15 min (ATM PE −70, CE +102), but it was covering, not new buying: the bounce faded and the day low came at 13:47, 125 pts lower.", 5:"The 13:47 day low after −330: Bank Nifty futures 10.6x, ICICIBANK 11.8x, 10 stocks ≥3x, yet OI flat (+32) and Nifty only +44/+20 at 15/30 min. Heavy volume here was two-way absorption — sellers met buyers, price went sideways. Low held the day (close +44 incl. auction) but broke the next session (21-Oct afternoon).", 6:"Jio-IPO day: the first leg down from 09:40 (−198) bottomed at 10:20 with 3.2x futures volume and fresh longs (OI +404, RELIANCE 9x). +58 in 15 min — then the longs got trapped: −17 by +60 min and three more legs down to 14:47.", 7:"Second leg (−294 from 10:42) bottomed at 12:23 with 3.7x futures volume, 10 stocks ≥3x and OI +471. Bounce +42 in 15 min, capped; new low at 14:47. Another case of 'heavy volume at the low' that was just a pause.", 8:"The 14:47 day low after −471 on 3.3x futures volume and OI −721 (shorts covering into the close). +40 in 15 min, +89 by 15:14 and +161 to the official close (auction lift). Next day gapped down 124 below it.", 9:"Gap-down day (Saudi attacks / oil). The 09:35 low (22,555) came on NORMAL volume for that minute (1.0x — the open is always busy), but option data shows heavy put writing at 22,550 (PE OI 1.6M → 3.8M units by 09:35) and BHARTIARTL 4.8x. +122 in 15 min (ATM PE −49, CE +59), +212 max. Held the day; broken next morning.", 10:"After a 200-pt slide from 11:14, the 12:18 low saw 4.6x futures volume, OI flat (+147). IT (TCS/INFY) turned first at 12:08–12:09, then ICICIBANK. A slow climb: +45/+60/+89 at 15/30/60 min; held the day.", 11:"A retest low at 13:27 (−155 from 12:55) with HDFCBANK at 10.8x volume and AXISBANK 5.6x but flat OI; +9 in 15 min, +46 in 30. Held the day, broken next morning.", 12:"Friday: −328 from 09:16 to 10:18. Futures 2.9x, BHARTIARTL 15.9x, OI +1,666 (indicative). +29/+40/+49 at 15/30/60 min — then rolled over to the 12:51 low, 87 lower. Fresh longs trapped again.", 13:"The 12:51 day low (22,372) after −415: futures 3.1x, ETERNAL 16.9x volume, OI rising. Initial bounce only +32 in 30 min. The real recovery came from the 13:38–13:42 burst (+137 futures pts, 8.7x volume, 16 stocks ≥3x) — +169 at +60 min, +228 to the official close. Catalyst: no confirmed news catalyst; futures-led spike (8.7x fut vol). Nearest event: FM chaired the 30th FSDC meeting ~12:16–13:28 IST, no market-moving remark found. The 13:55 tax headline was a repost of the 13-Oct story. (corrected 25-Oct; earlier version wrongly tied this burst to the FM's tax comments.)"} R['final_class']=R.low_id.map(FINAL); R.to_csv('results/lows_summary.csv',index=False) def T_(df,fmt=lambda v:f'{v:,.1f}'): return df.to_html(index=False,border=0,float_format=fmt,na_rep='—',escape=True) t1=R[['date','low_hm','kind','low','peak_hm','fall','rec15','rec30','rec60','max_up60','retrace60_pct','close1514_vs_low','official_close_vs_low','low_held_rest_of_day','next_day_low_held','final_class']].copy() t1.columns=['Date','Low @','Type','Low','Peak @','Fall','+15m','+30m','+60m','Max +60m','% of fall retraced (60m)','15:14 vs low','Official close vs low','Held rest of day','Held next day','Classification'] t2=R[['date','low_hm','fut_vr_max_m2_p3','bnfut_vr_at_low','n_stocks_3x_max','oi_chg_15m_before_contracts','oi_chg_15m_after_contracts','fut_bounce15','basis_at_low','basis_chg_m15_0','basis_chg_15','bn_low_rel','bn_rec15']].copy() t2.columns=['Date','Low @','Nifty fut vol x (max −2..+3m)','BankNifty fut vol x','# stocks ≥3x vol','Fut OI chg 15m before (contracts)','Fut OI chg 15m after','Fut pts +15m','Basis at low','Basis chg −15→0','Basis chg 0→+15','BankNifty low vs Nifty low (min)','BankNifty +15m'] t3=R[['date','low_hm','atm','expiry','ce_at_low','ce_15','ce_30','pe_at_low','pe_15','pe_30','ce_iv_low','ce_iv_15','pe_iv_m15','pe_iv_low','pe_iv_15','pe_oi_chg_atm_below_m15_p30','ce_oi_chg_atm_above_m15_p30','pcr_nearby_low','pcr_nearby_p30']].copy() t3.columns=['Date','Low @','ATM','Expiry','CE @low','CE +15','CE +30','PE @low','PE +15','PE +30','CE IV @low','CE IV +15','PE IV −15','PE IV @low','PE IV +15','PE OI chg ATM..−200 (−15→+30, contracts)','CE OI chg ATM..+200','PCR ±200 @low','PCR ±200 +30m'] t4=R[['date','low_hm','leaders15','leaders_volx','first_to_turn','n_hw_turned_before_index','leaders_deliv_vs20']].copy() t4.columns=['Date','Low @','Top 5 bounce contributors (Nifty pts, low→+15m)','Their max 1m volume x (−2..+5m)','Top-10 heavyweights: own low time','# heavyweights bottoming before index','Leaders day delivery % minus 20d'] news=R[['date','low_hm','news_pm10']].copy(); news.columns=['Date','Low @','Market headlines within ±10 min (local archive; [impact tag])'] news.iloc[:,2]=news.iloc[:,2].fillna('').str.slice(0,700) # part 2 tables z=Z[Z.period!='all'][['X','V','win','oi','period','n','r40_30','ctrl_r40_30','p_fisher','p_fisher_holm','hold60','ctrl_hold60','scale_n','scale_avg','scale_win%','scale_rand_avg','scale_p_vs_random','scale_p_vs_random_holm','sl_only_avg','sl_only_p_vs_random_holm']].copy() z.columns=['Fall X','Vol x','Window','OI filter','Period','n','P(+40 in 30m) %','Control %','p (Fisher, >control)','Holm p','Low holds 60m %','Control %','CE trades','CE avg pts (scale-out)','Win %','Random avg','p vs random','Holm p','CE avg pts (SL-only)','Holm p (SL-only)'] c=C.copy(); c.columns=['Kind','Fall X','Period','n','+40/15m %','+40/30m %','+80/30m %','+80/60m %','+120/60m %','Low holds 60m %','Low holds day %','Pts to 15:14'] po=PO.copy(); po.columns=['Kind','Fall X','Period','n','+40/15m %','+40/30m %','+80/30m %','+80/60m %','+120/60m %','Low holds 60m %','Low holds day %','Pts to 15:14'] y=Y[((Y.kind=='signal')&(Y.V==3)&(Y.win=='3bar')&(Y.oi=='any'))|(Y.kind=='control_quiet')][['kind','X','year','n','r40_15','r40_30','r80_30','r80_60','r120_60','hold60','hold_day','to_1514']].copy() y.columns=['Kind','Fall X','Year','n','+40/15m %','+40/30m %','+80/30m %','+80/60m %','+120/60m %','Low holds 60m %','Low holds day %','Pts to 15:14'] TT=T[(T.expiry_rule=='nearest')] yp=TT[(TT.kind=='signal')&(TT.V==3)&(TT.win=='3bar')&(TT.oi=='any')].assign(Year=lambda x:x.date.str[:4]).groupby(['X','style','Year']).pnl.agg(['count','mean','sum']).round(1).reset_index() yp=yp.merge(TT[TT.kind=='random'].assign(Year=lambda x:x.date.str[:4]).groupby(['style','Year']).pnl.mean().round(1).rename('random avg').reset_index(),on=['style','Year']) yp.columns=['Fall X','Exit style','Year','Trades','Avg pts','Total pts','Random avg pts'] s0=TT[TT.expiry_rule=='nearest'] bench=s0[s0.kind.isin(['random','control_quiet','price_bar','signal'])].groupby(['kind','style']).pnl.agg(n='count',avg='mean',median='median',win=lambda x:(x>0).mean()*100).round(2).reset_index() bench.columns=['Entry set','Exit style','Trades','Avg pts','Median pts','Win %'] # skip-0DTE sensitivity sk=T[(T.kind=='signal')&(T.V==3)&(T.win=='3bar')&(T.oi=='any')].groupby(['X','expiry_rule','style']).pnl.agg(['count','mean']).round(2).reset_index(); sk.columns=['Fall X','Expiry','Exit','Trades','Avg pts'] oc=EV[(EV.date>='2026-10-12')&(EV.kind=='signal')].sort_values(['date','hm']).groupby(['date','hm']).agg(configs=('X',lambda s:len(s)),X_max=('X','max'),dd=('dd','first'),fvr1=('fvr1','first'),fvr3=('fvr3','first'),mfe30=('mfe30','first'),mfe60=('mfe60','first'),hold60=('hold60','first')).reset_index() oc.columns=['Date','Signal @','# configs firing (first of day)','Max X met','Fall from high','Fut vol x (1m)','Fut vol x (3m)','Max up 30m','Max up 60m','Low held 60m'] best=Z[(Z.period=='train')&(Z.scale_n>=10)].sort_values('scale_avg',ascending=False).iloc[0] bt=Z[(Z.period=='test')&(Z.X==best.X)&(Z.V==best.V)&(Z.win==best.win)&(Z.oi==best.oi)].iloc[0] nlow=len(R); fv3=(R.fut_vr_max_m2_p3>=3).sum(); failed=(~R.low_held_rest_of_day).sum(); bnf=(R.bn_low_rel<0).sum() ce_gt=(R.ce_oi_chg_atm_above_m15_p30>R.pe_oi_chg_atm_below_m15_p30).sum(); nd=R.next_day_low_held.dropna(); ndh=(nd==True).sum() H=[f"""

7. Buying at the lows: what happens at the intraday bottoms (V-bounces)

Added 2026-10-25 after the question: "not only heavy buying days — heavy buying at the lows and recovering". Part 1 = every significant intraday low 12–23 Oct (day low + any swing low after a ≥150-pt fall that bounced ≥60; plus the 22-Oct gap-down low, fall 143), minute by minute from −15 to +45 min. Part 2 = is a pre-defined 'heavy buying at the low' signal tradable for an option buyer (Oct-2024 → Oct-2026)? Same data rules: Upstox historical 1m, spot/cash 15:15–15:29 treated as missing, nothing filled. Volume 'x' = this minute ÷ the same minute's average over the previous 20 sessions. Futures OI = Oct+Nov contracts from Upstox 1m (lagged/indicative; contracts = units ÷ 65).

Short version. Heavy futures volume (≥3x) showed up at {fv3} of the {nlow} lows — including most of the ones that failed. {failed} of the {nlow} lows were broken again the same day (09:54 on 12-Oct, 09:29 on 19-Oct, 10:20 and 12:23 on 21-Oct, 10:18 on 23-Oct), and only {ndh} of {len(nd)} held through the next session. At the lows that failed, the volume was either shorts covering (19-Oct 09:29, OI −949) or fresh longs that got trapped (21-Oct ×2, 23-Oct 10:18). The one clean 'real buying' low was 14-Oct 13:15 (fut 9.4x, OI +750, IT-led). Bank Nifty bottomed 1–8 minutes before Nifty at {bnf} of {nlow} lows; 2–6 of the top-10 heavyweights turned before the index. At the low, option writers sold both sides (CE OI added more than PE OI near ATM at {ce_gt} of {nlow} lows) and ATM IV barely moved (±0.5 vol) — the bounce shows up as put premium −5 to −70 in 15 min, not an IV collapse. Part 2 verdict: FAIL. Across 24 pre-set variants, the volume-spike signal did not beat the same reversal bar on quiet volume (no Holm-adjusted p < 1.0), and the ATM CE buy lost money in every period (e.g. best train variant X{int(best.X)}/{int(best.V)}x/{best.win}: train {best.scale_avg:+.1f} pts/trade, test {bt.scale_avg:+.1f} vs random {bt.scale_rand_avg:+.1f}).

Common pattern at the lows (12–23 Oct, n={nlow}; descriptive)

The {nlow} lows

""",T_(t1),"", "

Story per low (facts first, reading at the end of each)

"] for r in R.itertuples(): ok=r.low_held_rest_of_day H.append(f"

{r.date} {r.low_hm} — low {r.low:,.2f} after −{r.fall:.0f} ({r.kind})

{STORY[r.low_id]}

Numbers: fut vol {r.fut_vr_max_m2_p3:.1f}x, BankNifty fut {r.bnfut_vr_at_low:.1f}x, {int(r.n_stocks_3x_max)} stocks ≥3x; OI 15m after {r.oi_chg_15m_after_contracts:+,.0f} contracts; recovered {r.rec15:+.0f}/{r.rec30:+.0f}/{r.rec60:+.0f} at 15/30/60 min (max {r.max_up60:.0f}, {r.retrace60_pct:.0f}% of the fall); ATM {r.atm:.0f} PE {r.pe_at_low:.1f}→{r.pe_15:.1f}, CE {r.ce_at_low:.1f}→{r.ce_15:.1f} in 15 min. Leaders: {E_(str(r.leaders15))}.

Classification: {E_(r.final_class)}

") H+=["

Futures, OI, basis and Bank Nifty around each low

",T_(t2), "

Options at the low (near expiry; Black-76 IV on the synthetic forward)

",T_(t3),"

Option OI from Upstox 1m (units ÷ 65); first-bar OI is the previous day's value, so changes that span 09:15 include overnight updates. Option minutes with no trade are left blank.

", "

Which stocks led the bounce and turned first

",T_(t4), "

Headlines within ±10 minutes

",T_(news),"

Most headlines near a low describe the move ('Sensex falls 600 pts…') rather than cause it. Identified catalysts: 12-Oct 10:02–10:13 oil falls on Trump energy-truce comments; 23-Oct 13:38 burst: no confirmed news catalyst; futures-led spike (8.7x fut vol). Nearest event: FM chaired the 30th FSDC meeting ~12:16–13:28 IST, no market-moving remark found. The 13:55 tax headline was a repost of the 13-Oct story. Headlines whose quoted index move does not match the actual Nifty move, and later copies of reposted headlines, are now ignored (ignored list). Full list in results.

", f"""

8. Is 'heavy buying at the low' tradable for an option buyer? (Part 2)

Data used. Spot Nifty 1m 2022-01-03 → 2026-10-23 (research/common/data/nifty_1m_clean.parquet + Upstox historical for 21–23 Oct). Nifty futures 1m: Upstox expired-instruments historical API for every monthly contract Oct-2024 → Sep-2026, plus live Oct/Nov-2026 contracts (volume = sum of all listed contracts, so rollover is neutral). No 1m futures volume exists before Sep-2024 in any source we have (Upstox expired data starts with the Oct-2024 contract; S3 has none), so the volume signal can only be tested Oct-2024 → Oct-2026. The Dec-2024 contract is missing from Upstox (19 sessions 29-Nov → 26-Dec-2024 excluded, and the 20-day baseline restarts after). Options: ATM CE 1m from nifty-options-s3/…/1m/by_expiry (expiries 3-Oct-2024 → 29-Sep-2026) and Upstox for Oct-2026.

Split. The requested 2022–24 train / 2025–26 test is only possible for the price-only version. For the volume version: train = 21-Oct-2024 → 30-Jun-2025, test = 1-Jul-2025 → 23-Oct-2026, plus year by year.

Signal (fixed in advance; only data known at the bar close). Time 09:20–14:14. Day's running high − running low ≥ X (150/200/250) with the low set after the high and within the last 3 minutes. Trigger bar: closes in the top 40% of its range, above the previous close, and ≤25 pts above the low. Volume: Nifty futures volume ≥ V (3x/5x) of its 20-day same-minute average, on the trigger bar ('1bar') or averaged over the last 3 bars ('3bar'). Optional: futures OI fell over the last 3 min ('oi_down'). First trigger per day per variant → 3×2×2×2 = 24 variants. Control = the same reversal bar with futures volume <1.5x (bar and 3-bar). Base = first touch of the low after the same fall. Outcomes on spot from the trigger close, capped at 15:14.

Option trade. Buy the ATM CE (nearest expiry, 0DTE allowed; skip-0DTE as sensitivity) at the next minute's open + 3 pts; SL 20 pts; scale-out 40% at +20, 30% at +40 (stop to breakeven after +20, trail the rest 20 pts below the high after +40); time exit after 60 min. Exits −3 pts each; charges = ₹20/order, STT 0.1% sell, exchange 0.035%, SEBI, stamp, GST (≈1.1 pts/unit). If SL and target fall in the same minute, SL counts first. Random benchmark = 4,000 random minutes (09:20–14:14) with the same rules, bootstrap p-value; Holm correction across the 24 variants.

Verdict: FAIL. (1) Recovery odds after a volume-spike reversal are about the same as after the same bar on quiet volume (e.g. X150/3x/3bar: +40 within 30 min {Z[(Z.X==150)&(Z.V==3)&(Z.win=='3bar')&(Z.oi=='any')&(Z.period=='all')].r40_30.iloc[0]:.0f}% vs control {Z[(Z.X==150)&(Z.V==3)&(Z.win=='3bar')&(Z.oi=='any')&(Z.period=='all')].ctrl_r40_30.iloc[0]:.0f}%); smallest Holm-adjusted p = {Z[Z.period!='all'].p_fisher_holm.min():.2f}. (2) The low usually gets broken anyway: P(no new low in 60 min) ~0–25% for signals vs ~17–20% for controls. (3) ATM CE buying lost money in every variant with ≥10 trades, in train and in test, and lost more than random entries ({bench[(bench['Entry set']=='random')&(bench['Exit style']=='scale')]['Avg pts'].iloc[0]:+.2f} pts/trade). The few positive cells (X200–250 with OI filter, test) have 3–9 trades and do not survive correction. Best train variant (X{int(best.X)}/{int(best.V)}x/{best.win}/{best.oi}, {int(best.scale_n)} trades, {best.scale_avg:+.1f}) → test {int(bt.scale_n)} trades {bt.scale_avg:+.1f} pts vs random {bt.scale_rand_avg:+.1f}. Sample: 2 years; a regime change could change this, but nothing here supports trading it.

All 24 variants, train vs test

""",T_(z,lambda v:f'{v:,.2f}'), "

Controls (same period)

",T_(c),"

Price-only version (no volume), 2022–2026: train 2022–24 vs test 2025–26

",T_(po), "

price_bar = the reversal bar alone; at_low_any = first touch of the low after the fall. The reversal bar does not improve on the plain touch either: +40 in 30 min 25–41% vs 33–45%.

", "

Year by year (3-bar ≥3x variants vs control)

",T_(y),"

ATM CE P&L by year (3-bar ≥3x variants, nearest expiry)

",T_(yp), "

Benchmarks (all variants pooled, nearest expiry)

",T_(bench,lambda v:f'{v:,.2f}'),"

Sensitivity: skip same-day expiry

",T_(sk,lambda v:f'{v:,.2f}'), "

Where the signal fired in Oct-2026

",T_(oc),"

On 21-Oct the signal fired at 10:08, 11:37, 11:43 and 13:42 — all before the real 14:47 low. On 19-Oct it fired at 12:05/13:25/13:30, before the 13:47 low. This is why it loses: heavy volume near a running low happens during capitulation too, so the signal catches falling knives.

", "

Data notes for sections 7–8

", "

Files: lows/results/ (lows_summary, lows_minute_windows, lows_stock_bounce, p2_events, p2_option_trades, p2_config_stats, …) · scripts: lows/scripts/

"] open('section_lows.html','w').write('\n'.join(H)); print('section ok', len('\n'.join(H)))