tbs-dashboard / tbs_straddle_backtest_multi.py
iwilldoit's picture
Upload folder using huggingface_hub
9df26df verified
Raw
History Blame Contribute Delete
27.6 kB
# -*- coding: utf-8 -*-
"""tbs_straddle_backtest_multi.ipynb
Automatically generated by Colab.
Original file is located at
https://colab.research.google.com/drive/1kjWQHMHnt-6eac_ET5xqVLtEHBId2Zl4
# Nifty Time-Based Short Straddle / Strangle — Complete Multi-Moneyness Backtest
This notebook reproduces the **full** analysis set with two added features:
* **Interactive moneyness** — choose **ATM / OTM1 / OTM2 / ITM1 / ITM2**.
* **Minimum opening-straddle filter** — skip any expiry day whose ATM straddle at the opening time is below a threshold you pick (e.g. 75).
**What it contains (each framing threaded with your moneyness + min-straddle choice):**
1. Opening-straddle **trend plot** across the years (to pick the floor).
2. **09:30 entry → 15:30 exit** (single entry): leaderboard, PnL by SL, realized-vs-implied RR, year-by-year tables, annual & quarterly PnL by SL.
3. **15-minute iterative entries → 15:30 exit** (Morning / Mid-day / Afternoon baskets): master leaderboard, average PnL by basket & SL, annual PnL by basket & SL, realized-vs-implied RR by basket.
4. **2-hour window baskets**: master leaderboard, average PnL by basket & SL, realized-vs-implied RR by basket.
"""
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import re
from IPython.display import display
pd.set_option('display.width', 240)
pd.set_option('display.max_columns', 60)
# ----------------------------- CONFIG -----------------------------
DATA_PATH = "nifty_minute_chain.parquet"
OPEN_TIME = "09:30" # straddle gauge for the min-straddle filter (09:30 -> a 75 floor drops ~24 days)
ENTRY_TIME = "09:30" # single-entry default
EXIT_TIME = "15:30" # universal square-off
FRICTION = 0.001 # 0.1% per leg on entry + exit premium
# Strike offsets (points) from the ATM strike, per leg.
# CE off >0 = call above spot (OTM) ; CE off <0 = call below spot (ITM)
# PE off <0 = put below spot (OTM) ; PE off >0 = put above spot (ITM)
MONEYNESS = {
'ATM': {'ce_off': 0, 'pe_off': 0},
'OTM1': {'ce_off': +50, 'pe_off': -50},
'OTM2': {'ce_off': +100, 'pe_off': -100},
'ITM1': {'ce_off': -50, 'pe_off': +50},
'ITM2': {'ce_off': -100, 'pe_off': +100},
}
SL_LEVELS = list(range(10, 101, 10)) + [150, 200, 250, 300]
SL_LABELS = [f"{s}%" for s in SL_LEVELS]
ANNUALISE = np.sqrt(52) # weekly expiries -> 52 (matches the study methodology)
print("Moneyness:", list(MONEYNESS.keys()))
print("SL grid :", SL_LEVELS)
chain = pd.read_parquet(DATA_PATH)
chain['date_str'] = chain['date'].astype(str)
chain['hhmm'] = chain['hhmm'].astype(str).str.strip()
print(f"Rows: {chain.shape[0]:,} | Cols: {chain.shape[1]} | "
f"{chain['date_str'].min()} -> {chain['date_str'].max()} | "
f"expiry days: {chain.loc[chain['dte']==0,'date_str'].nunique()}")
chain.head(3)
"""## Step 1 — Opening-straddle trend across the years
Plotted **before** the backtest so you can see the volatility regime and choose a sensible minimum-straddle floor (dotted red line = example 75).
"""
d0 = chain[chain['dte'] == 0].copy()
open_strad = (d0[d0['hhmm'] == OPEN_TIME].dropna(subset=['straddle'])
.assign(dt=lambda x: pd.to_datetime(x['date_str']))
.sort_values('dt')[['dt','date_str','straddle','atm']].reset_index(drop=True))
open_strad['year'] = open_strad['dt'].dt.year
print(f"Expiry days with a valid {OPEN_TIME} straddle: {len(open_strad)}\n")
print(open_strad.groupby('year')['straddle'].agg(['count','min','median','mean','max']).round(1))
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(open_strad['dt'], open_strad['straddle'], marker='o', ms=3, lw=1, color='#16335c',
label=f'Opening straddle @ {OPEN_TIME}')
ax.plot(open_strad['dt'], open_strad['straddle'].rolling(8, min_periods=1).median(),
color='#e07b39', lw=2, label='8-expiry rolling median')
for yr, g in open_strad.groupby('year'):
m = g['straddle'].mean()
ax.hlines(m, g['dt'].min(), g['dt'].max(), color='green', ls='--', lw=1.2)
ax.text(g['dt'].iloc[len(g)//2], m + 4, f"{yr} avg {m:.0f}", color='green', fontsize=8, ha='center')
ax.axhline(75, color='red', ls=':', lw=1.5, label='Example floor = 75')
ax.set_title('Nifty ATM Opening Straddle on Expiry Days'); ax.set_xlabel('Date')
ax.set_ylabel('ATM straddle premium (points)'); ax.grid(alpha=0.3); ax.legend()
plt.tight_layout(); plt.show()
"""## Step 2 — Choose configuration (moneyness + minimum straddle)
The next cell prompts for the structure and the minimum opening straddle. It falls back to `ATM` / no-filter if run non-interactively; use the commented line to set them by hand.
"""
def ask_choice():
opts = list(MONEYNESS.keys())
try:
raw = input(f"Structure to test {opts}: ").strip().upper()
except Exception:
raw = ""
moneyness = raw if raw in MONEYNESS else "ATM"
if raw and raw not in MONEYNESS:
print(f" '{raw}' not recognised -> ATM")
try:
raw2 = input("Minimum opening straddle (e.g. 75, blank = none): ").strip()
except Exception:
raw2 = ""
try:
min_strad = float(raw2) if raw2 != "" else 0.0
except ValueError:
print(f" '{raw2}' not a number -> no filter"); min_strad = 0.0
return moneyness, min_strad
SELECTED_MONEYNESS, MIN_STRADDLE = ask_choice()
# Manual override:
# SELECTED_MONEYNESS, MIN_STRADDLE = "OTM1", 75
cfg = MONEYNESS[SELECTED_MONEYNESS]
elig = int((open_strad['straddle'] >= MIN_STRADDLE).sum())
print(f"\nStructure : {SELECTED_MONEYNESS} (CE {cfg['ce_off']:+d}, PE {cfg['pe_off']:+d})")
print(f"Min straddle : {MIN_STRADDLE}")
print(f"Eligible days : {elig}/{len(open_strad)} (straddle >= {MIN_STRADDLE} at {OPEN_TIME})")
"""## Step 3 — Build per-day minute panel (`grouped`)
Moneyness-independent: change the structure/threshold in Step 2 and just re-run the backtests; no need to rebuild this.
"""
work = chain[chain['dte'] == 0].copy()
dates_with_open = work[work['hhmm'] == OPEN_TIME]['date_str'].unique()
work = work[work['date_str'].isin(dates_with_open)].copy()
work = work.dropna(subset=['atm', 'straddle'])
work = work[(work['hhmm'] >= '09:15') & (work['hhmm'] <= '15:30')].copy()
time_range = pd.date_range('09:15', '15:30', freq='min').strftime('%H:%M').tolist()
grouped = {}
for date_str, g in work.groupby('date_str'):
rg = g.set_index('hhmm').reindex(time_range)
fcols = [c for c in rg.columns if c.startswith(('ce_','pe_')) or c in ('atm','straddle')]
rg[fcols] = rg[fcols].ffill()
if not rg.empty:
grouped[date_str] = rg
print(f"Days in panel: {len(grouped)} (min-straddle filter applied inside the backtest)")
# Fast numpy cache (rebuilt once; the backtest engine reads from this for speed).
PANEL = {}
for ds, df in grouped.items():
idx = {t: i for i, t in enumerate(df.index)}
cols = {c: df[c].to_numpy(dtype='float64')
for c in df.columns if c.startswith(('ce_', 'pe_')) or c == 'atm'}
so = df.loc[OPEN_TIME, 'straddle'] if OPEN_TIME in idx else np.nan
PANEL[ds] = {'idx': idx, 'atm': cols['atm'], 'cols': cols,
'open': float(so) if pd.notna(so) else np.nan}
print(f"Numpy panel cached for {len(PANEL)} days.")
"""## Step 4 — Backtest engine (moneyness + min-straddle filter)"""
_COLNAME = {}
def get_col_name(opt_type, delta):
k = (opt_type, delta)
if k not in _COLNAME:
_COLNAME[k] = (f'{opt_type}_atm' if delta == 0 else
(f'{opt_type}_p{delta}' if delta > 0 else f'{opt_type}_m{abs(delta)}'))
return _COLNAME[k]
def run_backtest(grouped, sl_pct, moneyness='ATM', min_straddle=0.0,
entry_time='09:30', exit_time='15:30'):
"""Fast panel-based engine. Sells the chosen structure at entry_time, squares off at
exit_time with independent leg-wise SL, and skips any day whose opening straddle
(at OPEN_TIME) is < min_straddle. Reads the precomputed PANEL for speed."""
ce_off = MONEYNESS[moneyness]['ce_off']; pe_off = MONEYNESS[moneyness]['pe_off']
ce_entry_col = get_col_name('ce', ce_off); pe_entry_col = get_col_name('pe', pe_off)
all_trades = []; skipped = 0
for date_str, p in PANEL.items():
idx = p['idx']
if entry_time not in idx or exit_time not in idx:
continue
op = p['open']
if np.isnan(op) or op < min_straddle:
skipped += 1; continue
ei = idx[entry_time]; xi = idx[exit_time]
atm = p['atm']; cols = p['cols']
ce_arr = cols.get(ce_entry_col); pe_arr = cols.get(pe_entry_col)
if ce_arr is None or pe_arr is None:
continue
eav = atm[ei]; ece = ce_arr[ei]; epe = pe_arr[ei]
if np.isnan(eav) or np.isnan(ece) or np.isnan(epe):
continue
entry_atm = int(eav); ce_strike = entry_atm + ce_off; pe_strike = entry_atm + pe_off
ce_sl_t = ece * (1 + sl_pct); pe_sl_t = epe * (1 + sl_pct)
ce_active = pe_active = True
ce_exit = ece; pe_exit = epe; last_ce = ece; last_pe = epe
ce_etime = pe_etime = exit_time; ce_reason = pe_reason = f'{exit_time} Sqoff'
for i in range(ei + 1, xi + 1):
if not ce_active and not pe_active: break
cav = atm[i]
if np.isnan(cav): continue
curr_atm = int(cav)
if ce_active:
arr = cols.get(get_col_name('ce', int(round((ce_strike - curr_atm) / 50.0) * 50)))
if arr is not None:
v = arr[i]
if not np.isnan(v):
last_ce = v
if v >= ce_sl_t:
ce_active = False; ce_exit = v; ce_reason = 'SL Hit'
if pe_active:
arr = cols.get(get_col_name('pe', int(round((pe_strike - curr_atm) / 50.0) * 50)))
if arr is not None:
v = arr[i]
if not np.isnan(v):
last_pe = v
if v >= pe_sl_t:
pe_active = False; pe_exit = v; pe_reason = 'SL Hit'
if ce_active: ce_exit = last_ce
if pe_active: pe_exit = last_pe
ce_cost = FRICTION * (ece + ce_exit); pe_cost = FRICTION * (epe + pe_exit)
ce_pnl = (ece - ce_exit) - ce_cost; pe_pnl = (epe - pe_exit) - pe_cost
all_trades.append({
'Date': date_str, 'Config': moneyness, 'Open_Straddle': round(op, 2),
'Entry_ATM': entry_atm, 'CE_Strike': ce_strike, 'PE_Strike': pe_strike,
'Entry_CE': round(ece, 2), 'Entry_PE': round(epe, 2),
'Exit_CE': round(ce_exit, 2), 'Exit_PE': round(pe_exit, 2),
'CE_Exit_Reason': ce_reason, 'PE_Exit_Reason': pe_reason,
'CE_PnL_Net': round(ce_pnl, 2), 'PE_PnL_Net': round(pe_pnl, 2),
'Day_PnL': round(ce_pnl + pe_pnl, 2),
})
run_backtest.last_skipped = skipped
return pd.DataFrame(all_trades)
def run_flexible_backtest(grouped_data, sl_pct, entry_time, exit_time,
moneyness='ATM', min_straddle=0.0):
return run_backtest(grouped_data, sl_pct, moneyness=moneyness, min_straddle=min_straddle,
entry_time=entry_time, exit_time=exit_time)
print("Engine ready (fast panel-based).")
"""### Shared metrics & plotting helpers (used by every framing)"""
def calc_metrics(trades_df, sl_label, config_key=None):
total = len(trades_df)
base = {'SL Level': sl_label, 'Trades': total, 'Win Rate %': 0, 'Implied RR': 0,
'Avg Winner': 0, 'Avg Loser': 0, 'Realized RR': 0, 'RR Efficiency': 0,
'Sharpe': 0, 'Profit Factor': 0, 'Max DD': 0, 'Total PnL': 0}
if config_key is not None: base = {'Config Key': config_key, **base}
if total == 0:
return base
pnl = trades_df['Day_PnL'].values
winners = pnl[pnl > 0]; losers = pnl[pnl < 0]
win_rate = len(winners) / total * 100
avg_w = winners.mean() if len(winners) else 0
avg_l = losers.mean() if len(losers) else 0
realized_rr = avg_w / abs(avg_l) if avg_l != 0 else 0
sl_num = float(sl_label.replace('%', '')) / 100.0
implied_rr = 1.0 / sl_num if sl_num > 0 else 0
rr_eff = realized_rr / implied_rr if implied_rr > 0 else 0
sharpe = (pnl.mean() / pnl.std() * ANNUALISE) if pnl.std() > 0 else 0
cum = np.cumsum(pnl); peak = np.maximum.accumulate(cum); max_dd = (cum - peak).min()
gw = winners.sum() if len(winners) else 0
gl = abs(losers.sum()) if len(losers) else 0.01
base.update({'Win Rate %': round(win_rate, 1), 'Implied RR': round(implied_rr, 2),
'Avg Winner': round(avg_w, 2), 'Avg Loser': round(avg_l, 2),
'Realized RR': round(realized_rr, 2), 'RR Efficiency': round(rr_eff, 2),
'Sharpe': round(sharpe, 2), 'Profit Factor': round(gw / gl, 2),
'Max DD': round(max_dd, 2), 'Total PnL': round(pnl.sum(), 2)})
return base
def _sl_palette(labels):
order = sorted(labels, key=lambda x: float(x.replace('%', '')))
base = sns.color_palette('viridis', len(order))
return order, {sl: ('red' if sl == '300%' else base[i]) for i, sl in enumerate(order)}
def run_basket_set(time_configs):
"""Run the selected moneyness + filter across every (entry,exit) config x SL level."""
results = {}
for c in time_configs:
key = f"Basket: {c['basket']}, Entry: {c['entry']}, Exit: {c['exit']}"
results[key] = {}
for sl in SL_LEVELS:
results[key][f"{sl}%"] = run_flexible_backtest(
grouped, sl / 100.0, c['entry'], c['exit'],
moneyness=SELECTED_MONEYNESS, min_straddle=MIN_STRADDLE)
return results
def build_master(all_flex):
rows = [calc_metrics(t, sl, cfg_key) for cfg_key, d in all_flex.items() for sl, t in d.items()]
df = pd.DataFrame(rows)
df[['Basket', 'Entry Time', 'Exit Time']] = df['Config Key'].str.extract(
r'Basket: ([\w-]+), Entry: (\d{2}:\d{2}), Exit: (\d{2}:\d{2})')
return df
BASKET_ORDER = ['Morning', 'Mid-day', 'Afternoon']
print("Helpers ready.")
"""# Framing 1 — Single 09:30 entry → 15:30 exit"""
print(f"[{SELECTED_MONEYNESS} | min straddle {MIN_STRADDLE}] single entry {ENTRY_TIME} -> {EXIT_TIME}\n")
all_results = {}
for sl in SL_LEVELS:
all_results[f"{sl}%"] = run_backtest(grouped, sl/100.0, SELECTED_MONEYNESS, MIN_STRADDLE,
entry_time=ENTRY_TIME, exit_time=EXIT_TIME)
print(f"Days skipped by filter: {run_backtest.last_skipped}")
for lab, t in all_results.items():
print(f" SL {lab:>4}: {len(t):3d} trades | total PnL {t['Day_PnL'].sum():9.1f}")
"""### Leaderboard (single entry)"""
leader_df = (pd.DataFrame([calc_metrics(t, lab) for lab, t in all_results.items()])
.sort_values('Sharpe', ascending=False).reset_index(drop=True))
print(f"LEADERBOARD | {SELECTED_MONEYNESS} | min straddle {MIN_STRADDLE} (by Sharpe)")
display(leader_df)
"""### Total PnL by stop-loss level (single entry)"""
totals = {k: v['Day_PnL'].sum() for k, v in all_results.items()}
plt.figure(figsize=(11, 5))
bars = plt.bar(list(totals), list(totals.values()),
color=['#b3261e' if x < 0 else '#2e7d32' for x in totals.values()])
plt.title(f"Total PnL by Stop-Loss — {SELECTED_MONEYNESS} | min straddle {MIN_STRADDLE}")
plt.xlabel("Stop-Loss level"); plt.ylabel("Total PnL (points)")
plt.axhline(0, color='k', lw=0.8); plt.grid(axis='y', alpha=0.3)
for b, v in zip(bars, totals.values()):
plt.text(b.get_x()+b.get_width()/2, v, f"{v:.0f}", ha='center',
va='bottom' if v >= 0 else 'top', fontsize=8)
plt.tight_layout(); plt.show()
"""### Realized vs Implied Reward-to-Risk (single entry)"""
order, _ = _sl_palette(leader_df['SL Level'])
rr = leader_df.set_index('SL Level').loc[order]
plt.figure(figsize=(12, 5))
plt.plot(order, rr['Realized RR'], marker='o', label='Realized RR')
plt.plot(order, rr['Implied RR'], marker='o', label='Implied RR')
plt.title(f"Realized vs Implied RR Across Stop-Loss Levels — {SELECTED_MONEYNESS}")
plt.xlabel("Stop-Loss level"); plt.ylabel("Reward-to-Risk ratio")
plt.grid(alpha=0.3, ls='--'); plt.legend(); plt.tight_layout(); plt.show()
"""### Year-by-year metrics, and annual / quarterly PnL by SL (single entry)"""
# Year-by-year metric tables
yearly_rows = []
for sl_label, t in all_results.items():
tt = t.copy(); tt['Year'] = pd.to_datetime(tt['Date']).dt.year
for yr, ydf in tt.groupby('Year'):
m = calc_metrics(ydf, sl_label); m['Year'] = yr; yearly_rows.append(m)
yearly_performance_df = (pd.DataFrame(yearly_rows)
.sort_values(['SL Level', 'Year']).reset_index(drop=True))
yearly_performance_df = yearly_performance_df[['Year', 'SL Level'] +
[c for c in yearly_performance_df.columns if c not in ('Year', 'SL Level')]]
for yr in sorted(yearly_performance_df['Year'].unique()):
print(f"--- Year {yr} ---")
display(yearly_performance_df[yearly_performance_df['Year'] == yr].drop(columns='Year'))
def plot_annual_returns_grouped(all_results):
rows = []
for sl, t in all_results.items():
tt = t.copy(); tt['Year'] = pd.to_datetime(tt['Date']).dt.year
s = tt.groupby('Year')['Day_PnL'].sum().reset_index(); s['SL Level'] = sl; rows.append(s)
comb = pd.concat(rows)
order, pal = _sl_palette(comb['SL Level'].unique())
plt.figure(figsize=(12, 7))
ax = sns.barplot(data=comb, x='Year', y='Day_PnL', hue='SL Level', hue_order=order, palette=pal)
plt.title(f'Annual Returns by SL Level (Market Regime) — {SELECTED_MONEYNESS}')
plt.ylabel('Total PnL (Points)'); plt.xlabel('Year'); plt.grid(axis='y', ls='--', alpha=0.6)
plt.legend(title='SL Level', bbox_to_anchor=(1.02, 1), loc='upper left')
for c in ax.containers: ax.bar_label(c, fmt='%.0f', fontsize=8, padding=2)
plt.tight_layout(); plt.show()
plot_annual_returns_grouped(all_results)
def plot_yearly_pnl_by_sl(yearly_df):
df = yearly_df.copy()
order, pal = _sl_palette(df['SL Level'].unique())
df['SL Level'] = pd.Categorical(df['SL Level'], categories=order, ordered=True)
g = sns.catplot(data=df, x='SL Level', y='Total PnL', hue='SL Level', hue_order=order,
col='Year', kind='bar', col_wrap=2, height=4.2, aspect=1.3,
palette=pal, sharey=False, legend=False)
g.set_axis_labels("Stop-Loss level", "Total PnL (Points)"); g.set_titles("Year: {col_name}")
g.fig.suptitle(f'Total PnL by SL Level (by Year) — {SELECTED_MONEYNESS}', y=1.02, fontsize=15)
for ax in g.axes.flat:
for c in ax.containers: ax.bar_label(c, fmt='%.0f', fontsize=7, padding=2)
ax.grid(axis='y', ls='--', alpha=0.6); ax.tick_params(axis='x', rotation=45)
plt.tight_layout(rect=[0, 0.03, 1, 0.98]); plt.show()
plot_yearly_pnl_by_sl(yearly_performance_df)
def plot_quarterly_returns(all_results):
rows = []
for sl, t in all_results.items():
tt = t.copy(); tt['Date'] = pd.to_datetime(tt['Date'])
tt['Year'] = tt['Date'].dt.year; tt['Quarter'] = tt['Date'].dt.quarter
s = tt.groupby(['Year', 'Quarter'])['Day_PnL'].sum().reset_index(); s['SL Level'] = sl
rows.append(s)
comb = pd.concat(rows).reset_index(drop=True)
comb['Quarter'] = pd.Categorical(comb['Quarter'], categories=[1, 2, 3, 4], ordered=True)
order, pal = _sl_palette(comb['SL Level'].unique())
comb['SL Level'] = pd.Categorical(comb['SL Level'], categories=order, ordered=True)
g = sns.catplot(data=comb, x='Quarter', y='Day_PnL', hue='SL Level', hue_order=order,
col='Year', kind='bar', col_wrap=2, height=4.2, aspect=1.3,
palette=pal, sharey=False, legend_out=True)
g.set_axis_labels("Quarter", "Total PnL (Points)"); g.set_titles("Year: {col_name}")
g.fig.suptitle(f'Quarterly Returns by SL Level (by Year) — {SELECTED_MONEYNESS}', y=1.02, fontsize=15)
for ax in g.axes.flat:
for c in ax.containers: ax.bar_label(c, fmt='%.0f', fontsize=7, padding=2)
ax.grid(axis='y', ls='--', alpha=0.6)
plt.tight_layout(rect=[0, 0.03, 1, 0.98]); plt.show()
plot_quarterly_returns(all_results)
"""# Framing 2 — 15-minute iterative entries → 15:30 exit (time baskets)
Morning, Mid-day and Afternoon windows; every entry exits at 15:30. Morning starts at `ENTRY_TIME` (09:30) so the trade is never opened before the straddle gauge used by the filter.
"""
def gen_entries(start, end, step=15):
out, t, e = [], pd.to_datetime(start, format='%H:%M'), pd.to_datetime(end, format='%H:%M')
while t <= e:
out.append(t.strftime('%H:%M')); t += pd.Timedelta(minutes=step)
return out
time_configs = []
for e in gen_entries('09:30', '11:15'): time_configs.append({'entry': e, 'exit': '15:30', 'basket': 'Morning'})
for e in gen_entries('11:30', '13:15'): time_configs.append({'entry': e, 'exit': '15:30', 'basket': 'Mid-day'})
for e in gen_entries('13:30', '15:15'): time_configs.append({'entry': e, 'exit': '15:30', 'basket': 'Afternoon'})
print(f"15-min configs: {len(time_configs)} | total backtests: {len(time_configs)*len(SL_LEVELS)}")
all_flexible_results = run_basket_set(time_configs)
master_leader_df = build_master(all_flexible_results)
print("\nMaster leaderboard (top by Sharpe):")
display(master_leader_df.sort_values('Sharpe', ascending=False)
.drop(columns='Config Key').head(10).reset_index(drop=True))
"""### Average total PnL per basket & SL — 15-min (across all entry times)"""
def plot_avg_pnl_by_basket_sl(master_df, suffix):
agg = master_df.groupby(['Basket', 'SL Level'])['Total PnL'].mean().reset_index()
order, pal = _sl_palette(agg['SL Level'].unique())
agg['Basket'] = pd.Categorical(agg['Basket'], categories=BASKET_ORDER, ordered=True)
plt.figure(figsize=(14, 7))
ax = sns.barplot(data=agg, x='Basket', y='Total PnL', hue='SL Level', hue_order=order, palette=pal)
plt.title(f'Average Total PnL per Basket and SL Level ({suffix}) — {SELECTED_MONEYNESS}')
plt.xlabel('Time Basket'); plt.ylabel('Average Total PnL (Points)')
plt.legend(title='SL Level', bbox_to_anchor=(1.02, 1), loc='upper left')
plt.grid(axis='y', ls='--', alpha=0.6)
for c in ax.containers: ax.bar_label(c, fmt='%.0f', fontsize=7, padding=2)
plt.tight_layout(); plt.show()
plot_avg_pnl_by_basket_sl(master_leader_df, 'Across All Entry Times, 15-min')
"""### Average annual PnL by basket & SL — 15-min (faceted by year)"""
def plot_avg_pnl_by_basket_sl_yearly(all_flex, suffix):
rows = []
for key, d in all_flex.items():
bm = re.search(r'Basket: ([\w-]+)', key); basket = bm.group(1) if bm else 'Unknown'
for sl, t in d.items():
if t.empty: continue
tt = t.copy(); tt['Year'] = pd.to_datetime(tt['Date']).dt.year
s = tt.groupby('Year')['Day_PnL'].sum().reset_index()
s['SL Level'] = sl; s['Basket'] = basket; rows.append(s)
comb = pd.concat(rows)
avg = (comb.groupby(['Year', 'Basket', 'SL Level'])['Day_PnL'].mean().reset_index()
.rename(columns={'Day_PnL': 'Average Annual PnL'}))
order, pal = _sl_palette(avg['SL Level'].unique())
avg['Basket'] = pd.Categorical(avg['Basket'], categories=BASKET_ORDER, ordered=True)
g = sns.catplot(data=avg, x='Basket', y='Average Annual PnL', hue='SL Level', hue_order=order,
col='Year', kind='bar', col_wrap=2, height=4.2, aspect=1.3,
palette=pal, sharey=False, legend_out=True)
g.set_axis_labels("Time Basket", "Average Annual PnL (Points)"); g.set_titles("Year: {col_name}")
g.fig.suptitle(f'Average Annual PnL by Basket and SL Level ({suffix}) — {SELECTED_MONEYNESS}',
y=1.02, fontsize=15)
for ax in g.axes.flat:
for c in ax.containers: ax.bar_label(c, fmt='%.0f', fontsize=6, padding=2)
ax.grid(axis='y', ls='--', alpha=0.6)
plt.tight_layout(rect=[0, 0.03, 1, 0.98]); plt.show()
plot_avg_pnl_by_basket_sl_yearly(all_flexible_results, '15-min')
"""### Realized vs Implied RR per basket — 15-min"""
def plot_rr_by_basket(master_df, suffix):
df = master_df.copy(); df['SL Numeric'] = df['SL Level'].str.replace('%', '').astype(float)
rr = (df.groupby(['Basket', 'SL Level', 'SL Numeric'])
.agg(Average_Realized_RR=('Realized RR', 'mean'),
Average_Implied_RR=('Implied RR', 'mean')).reset_index()
.sort_values(['Basket', 'SL Numeric']))
rr['Basket'] = pd.Categorical(rr['Basket'], categories=BASKET_ORDER, ordered=True)
melt = pd.melt(rr, id_vars=['Basket', 'SL Level', 'SL Numeric'],
value_vars=['Average_Realized_RR', 'Average_Implied_RR'],
var_name='RR Type', value_name='RR Value')
g = sns.FacetGrid(melt, col='Basket', col_wrap=3, height=4.5, aspect=1.2,
sharey=False, col_order=BASKET_ORDER)
g.map_dataframe(sns.lineplot, x='SL Level', y='RR Value', hue='RR Type', marker='o')
g.set_axis_labels("Stop-Loss level", "Reward-to-Risk ratio"); g.set_titles("Basket: {col_name}")
g.add_legend(title='RR Type')
g.fig.suptitle(f'Average Realized vs Implied RR per Basket ({suffix}) — {SELECTED_MONEYNESS}',
y=1.02, fontsize=15)
for ax in g.axes.flat:
ax.tick_params(axis='x', rotation=45); ax.grid(True, ls='--', alpha=0.6)
plt.tight_layout(rect=[0, 0.03, 1, 0.98]); plt.show()
plot_rr_by_basket(master_leader_df, '15-min')
"""# Framing 3 — 2-hour window baskets
Four staggered entries per basket collapsing to a single ~2-hour exit (Morning→11:30, Mid-day→13:30, Afternoon→15:30).
"""
time_configs_2hr = []
for e in ['09:16','09:20','09:25','09:30']: time_configs_2hr.append({'entry': e, 'exit': '11:30', 'basket': 'Morning'})
for e in ['11:16','11:20','11:25','11:30']: time_configs_2hr.append({'entry': e, 'exit': '13:30', 'basket': 'Mid-day'})
for e in ['13:16','13:20','13:25','13:30']: time_configs_2hr.append({'entry': e, 'exit': '15:30', 'basket': 'Afternoon'})
print(f"2-hour configs: {len(time_configs_2hr)} | total backtests: {len(time_configs_2hr)*len(SL_LEVELS)}")
all_flexible_results_2hr = run_basket_set(time_configs_2hr)
master_leader_df_2hr = build_master(all_flexible_results_2hr)
print("\nAverage performance per 2-hour basket:")
display(master_leader_df_2hr.groupby('Basket').agg(
Average_Total_PnL=('Total PnL', 'mean'), Average_Sharpe=('Sharpe', 'mean'),
Average_Win_Rate=('Win Rate %', 'mean'), Average_Realized_RR=('Realized RR', 'mean'),
Average_Implied_RR=('Implied RR', 'mean')).reindex(BASKET_ORDER).round(2))
"""### Average total PnL per basket & SL — 2-hour"""
plot_avg_pnl_by_basket_sl(master_leader_df_2hr, '2-Hour Baskets')
"""### Realized vs Implied RR per basket — 2-hour"""
plot_rr_by_basket(master_leader_df_2hr, '2-Hour Baskets')
"""---
### Notes
- **Filter** = trade a day only if its ATM straddle at `OPEN_TIME` (default 09:30) ≥ `MIN_STRADDLE`. It applies identically to all three framings (a day-level eligibility gate), so the day-set is consistent across them. Switch `OPEN_TIME` to `09:15` for the true open (a 75 floor then removes far fewer days).
- **Re-running a different structure/threshold**: re-run Step 2, then the framing cells. `grouped` (Step 3) is moneyness-independent.
- **Sharpe** uses √52 (weekly expiries) consistently. **Stop-loss** is a % of collected premium, so the same SL% is a much smaller spot move on ITM than OTM — not comparable across moneyness in spot terms.
- Excluded the 5-minute / 1-minute density sweeps (not in your list); the three requested framings are all here.
"""