import pandas as pd,numpy as np,datetime as dt,html,json L='/workspace/research/buyer-method-audit'; R=lambda f:pd.read_csv(f'{L}/results/{f}') now=dt.datetime.now(dt.timezone(dt.timedelta(hours=5,minutes=30))).strftime('%Y-%m-%d %H:%M IST') def tb(df,fl=2): return df.to_html(index=False,float_format=lambda x:f'{x:,.{fl}f}',border=0,na_rep='–') css=open('/workspace/research/flow-forensics-2026-10/index.html').read().split('')[0] CH=json.load(open(f'{L}/results/frozen_choices.json')) F=R('flaw_cost_exit_pooled.csv'); fp=F.pivot(index='ex',columns='scen',values='pnl') P=R('primary_results.csv'); PT=P[['cand','ex','split','n','mean','median','win','avg_win','avg_loss','payoff','rs_per_trade','rs_total','maxdd_rs','worst_month','worst_month_rs','random_mean','random_pctile','t_p_one','q_bh']] SP=R('spread_by_tod.csv'); SD=R('spread_by_dte.csv'); SO=R('spread_by_offset.csv') Y=R('chosen_by_year.csv'); G=R('grid_all_exits_scen.csv'); Gp=G[G.scen=='REAL'].pivot_table(index=['cand','ex'],columns='split',values=['n','mean']).round(2); Gp.columns=[f'{a}_{b}' for a,b in Gp.columns]; Gp=Gp.reset_index() TOD=R('random_by_tod.csv'); TODp=TOD.pivot(index='hm',columns='ex',values='pnl').reset_index() WV=R('random_with_vs_counter.csv'); SPX=R('spot_proxy_by_year.csv'); SPX=SPX[SPX.ex.isin(['E1','E4'])] DI=R('deep_itm_orb_E1_summary.csv'); DI=DI[DI.scen=='REAL'] names={'A':'O30 >150 continuation','B15':'ORB15 with-gap','B30':'ORB30 with-gap','C':'Rule B early-low rebound (CE)','D':'First-hour VWAP pullback','E':'Trend-day filter'} PT.insert(1,'name',PT.cand.map(names)) te=P[P.split=='test'].set_index('cand') H=f"""Audit of our option-buyer backtest method — what we were doing wrong, and the re-run

Are our backtests biased against option buying? Audit, fixes and a clean re-run

Built {now}. Research only, not advice. Read-only data, no orders. Asked 26-Oct-2026 17:11 IST: “you are not able to find 1 strategy in option buying which is profitable, you are doing something wrong, there are thousands of strategies and you say all of them lose.”

Update 26-Oct-2026 (asked 18:06 IST): “trail the profit at 50% after 1:2” re-run → section 6. It does not change the verdict.

Short answer.
  1. Yes, we got one big thing wrong: costs. Earlier pages (recoveries, lows, call-wall, pre-expiry, last-30, overnight) charged 3 pts slippage per side on ATM weekly options. The real Upstox order book (31,096 snapshots, 11 sessions, Oct-2026) shows a median ATM/±1 bid-ask spread of 0.25 pt (90th percentile 0.40). Top-of-book size is about 9–10 lots. A realistic fill is ≈0.5 pt per side. That is about 2.0 pts (≈₹133/lot of 65) per 2-order round trip including all charges, not ≈7. Fixing it improves every result by ≈+4.9 pts/trade. The famous “random entries lose −9” falls to about −2.5.
  2. The other suspected flaws were checked and are small. Buyer-style exits (structural spot stop, no scale-out, hold to 15:14, 2R/4R targets, ATR-like trail) do not beat the old scale-out on average. Pooled across 804 trades: trail E4 {fp.loc['E4','REAL']:+.2f}, old E0 {fp.loc['E0','REAL']:+.2f}, 2R {fp.loc['E2','REAL']:+.2f}, 4R {fp.loc['E3','REAL']:+.2f}, hold-to-close {fp.loc['E1','REAL']:+.2f} pts. With-trend beats counter-trend by only ≈0.2–0.7 pt. First-hour entries are no cheaper than mid-day. Strike choice (ITM/ATM/OTM) moves results by ±1 pt. No lookahead was found in the old code.
  3. Even after the fixes, none of the 6 pre-declared candidates is profitable out of sample. In 2026, 5 of 6 are negative. The trend-day filter is {te.loc['E','mean']:+.2f} pts/trade (n={te.loc['E','n']:.0f}, p={te.loc['E','t_p_one']:.2f}, BH q=1). None survives BH. Most sit at or below random same-time entries with the same exits.
  4. Where the edge really is: ORB with-gap plus a structural stop held to 15:14 makes +5.9 (ORB15) / +8.1 (ORB30) Nifty points per trade on spot, 2022–2026, positive in every year (ORB30 p=0.04 before multiple-testing correction). The option buyer keeps only ≈25–50% of that move. Theta and the intraday IV drift eat the rest, then about 2 pts of cost, so the trades end at −1 to −5 pts. The problem is not that we test the wrong patterns. Intraday spot edges of 5–10 points are smaller than what a long option bleeds over the hours needed to collect them.
  5. A lead, not a strategy (found after looking at 2026, so it is post-hoc): the put side of ORB with-gap (gap-down day, breaks the opening range down, 2–6 strikes ITM, structural stop, hold to 15:14) made +5.6 to +7.4 pts/trade in 2026 (n≈50) but about 0 in 2024–25 (−3.5 to +2.6). It goes on a forward-watch list, not a trade list.

1. What we were doing wrong (measured)

#Suspected flawEvidence in our codeVerdictMeasured impact
1Slippage 3 pts/sideSLIP=3.0 in recov/tradelib.py, lows/part2_options.py, callwall/cwlib.py, pre-expiry/backtest_pre.py, last30/options_pnl.py, overnight/backtest.py. (nifty-setups-backtest used 0.3–0.5, which is fine.)CONFIRMED, the main flawOLD→REAL: E0 {fp.loc['E0','OLD']:+.2f}→{fp.loc['E0','REAL']:+.2f}; E1 {fp.loc['E1','OLD']:+.2f}→{fp.loc['E1','REAL']:+.2f} pts/trade, i.e. +4.9 pts/trade (≈₹320/lot). Stress at 1 pt/side costs another −1.0.
1bSTTOld code used 0.10% on all sells. It has been 0.15% since 1-Apr-2026 (Finance Act 2026, NSE circular).Confirmed, tiny≈−0.07 pt/trade in 2026 (it made old 2026 results slightly too good)
2Premium stop −20 + scale-out cut the right tailE0 in tradelib/cwlib: stop −20, 40% at +20, 30% at +40, 60-min capNot confirmedPooled REAL: E0 {fp.loc['E0','REAL']:+.2f} vs structural stop + hold to 15:14 {fp.loc['E1','REAL']:+.2f}, 4R {fp.loc['E3','REAL']:+.2f}, 2R {fp.loc['E2','REAL']:+.2f}, trail {fp.loc['E4','REAL']:+.2f}. Longer holds raise the payoff ratio (1.4–2.7) but win rates drop to 20–35% and theta is paid for longer. Only the trail is better, by ≈+0.4.
3Entries were countertrend or late; first hour ignoredrecov and lows were rebound fades. callwall was a mid-day cross.Partly true, smallRandom entries: with-trend {WV.with_trend.mean():+.2f} vs counter {WV.counter.mean():+.2f} (first hour {WV[WV.hm.isin(['09:30','09:45','10:15'])].with_trend.mean():+.2f} vs {WV[WV.hm.isin(['09:30','09:45','10:15'])].counter.mean():+.2f}). First-hour random entries {TOD[TOD.hm.isin(['09:20','09:30','09:45'])].pnl.mean():+.2f} vs later {TOD[~TOD.hm.isin(['09:20','09:30','09:45'])].pnl.mean():+.2f}, so no free first-hour edge. All 6 candidates here are with-trend and first-hour.
4aLookahead / same-bar entryAll old option sims fill at the next minute's open. Stops fill at min(open, stop).Not found0. One small optimism: recov 'spotstop' exits at the same bar's close.
4bWrong strikeATM only in recov/callwallMinorITM1/ITM2/OTM1 vs ATM: −0.1 to +0.6 pt on average. 0DTE is worse (−0.5 to −1.1).
4cSpot close vs executable option priceOld trades already used real 1-min option barsNot a flaw–
4dToo many variants / BH kills everythingcallwall picked 1 of 440 combos per side. Last30 had dozens of variants.Real issue (it cuts both ways)Here the family is fixed at 6 tests, declared before 2026 was opened. Nothing is positive enough for BH to even matter: the best raw p is {te.t_p_one.min():.2f}.
4e60-min hold caprecov/callwall used 60 min (runner 240)Tested via E1–E4 (hold to 15:14)Holding longer did not help on average (see #2)
4fSessions excluded / missing dataSpot 15:15–15:29 frozen since 2026-08-03 is treated as missing. Options are real to 15:40.HandledThe time exit is at the 15:15 option open, so the frozen window is never used.

2. Corrected cost per trade

Bid-ask from the Upstox ATM-feed snapshots (strikes ATM±2, CE+PE, 08–26 Oct 2026). Spread in option points:

{tb(SP,3)}{tb(SD,3)}{tb(SO,3)}

Charges (2-order round trip, ATM premium ≈145, lot 65): brokerage ₹40, STT 0.15% of sell premium ≈₹14, NSE txn 0.03503% ≈₹7, GST 18% on brokerage+txn ≈₹8.5, SEBI and stamp <₹1. Total ≈₹68 = 1.04 pts. Scale-out exits use 3–4 orders: 1.43–1.96 pts. Slippage: 0.5 pt/side used as REAL, against a measured half-spread of ≈0.13. That leaves room for a tick and for faster minutes. Stress case 1.0 pt/side. Total REAL ≈ 2.0 pts/trade (₹133/lot) versus the old ≈7.0–8.0. Caveat: the spreads come from calm-to-normal Oct-2026 sessions. Gap opens and news minutes can be wider.

3. Re-run: 6 pre-declared candidates (PREREG.md, choices frozen in frozen_choices.json at {CH['_fixed_at_ist']} before any 2026 number)

Instrument: ATM, nearest weekly with DTE≥1, CE for long / PE for short. Entry at the next minute's option open after a spot-bar-close signal. Exits are decided on spot and executed at the next option open. Time exit at the 15:15 option open. R = structural stop distance clipped to 15–70 spot pts. Exit set E0 legacy / E1 structural stop + time / E2 2R / E3 4R / E4 BE at 1R then trail 1R. The exit is chosen per candidate on TRAIN (Oct-2024..Dec-2025), then tested once on 2026 (to 23-Oct). REAL cost. Random = same-time, same-direction, same-R entries on random days of the same period with the same exit; percentile = rank of the candidate mean among 2,000 random same-size sets. ₹ per lot uses lot 75 in 2024–25 and 65 in 2026.

{tb(PT)}

Per year (chosen exit, REAL cost)

{tb(Y,1)}

per-month CSV

Secondary: every exit, REAL cost (exploratory, not in the family)

{tb(Gp)}

The trend-day filter (E) with the trail E4 is +18.4 pts in 2026, but −9.8 in train. It was the worst-ranked choice on train, so taking it now would be fitting to 2026. Not claimed.

4. Why buyers still lose: the spot edge exists, the option eats it

Spot-only proxy (index points, no costs, same signal and exit; 2022–23 has no futures volume, so VWAP-based D/E start in Oct-2024):

{tb(SPX,1)}

ORB with-gap held to 15:14 (E1): spot points vs what the option buyer keeps after REAL cost, by strikes ITM:

{tb(DI)}

By direction (all periods): on short signals spot gains +13.3 pts, the ATM put gains +3.2 before costs, and the 6-ITM put +6.6. On long signals spot loses −3.9 and calls lose −6. Over 3–5 hours a long weekly option gives back roughly 3–6 pts to theta and the falling intraday IV. That is the same size as the spot edge.

Random ATM buys by entry time (REAL cost)

{tb(TODp)} """+open(f'{L}/section_trail50.html').read()+f"""

5. Data, limits, gaps

Files: results · scripts · charts · pre-registration. Related: intraday recoveries, flow forensics, opening-30m rule.

""" open(f'{L}/index.html','w').write(H); print('written',len(H))