free expiry day ↓

blog /

Building an expiry-day dataset in Python: pandas and DuckDB recipes

Turn a pack of daily option files into one table of expiry sessions, with minutes to expiry, nearest strike and normalised column names. Works with old and new file layouts.

A pack arrives as thousands of daily files, one per trading day per expiry. For expiry-day research you want something else: one table holding only the expiry sessions, with the same column names throughout and a few derived fields. Here is a pipeline that does that in a few minutes on a laptop.

1. Pick the expiry files

An expiry session is a file whose trade date equals its expiry date. Both are in the file name, so no file has to be opened to choose.

import re
from pathlib import Path

pat = re.compile(r"_OPT_1min_(\d{4}-\d{2}-\d{2})_exp(\d{4}-\d{2}-\d{2})")
files = [f for f in Path("data").rglob("*.parquet")
         if (m := pat.search(f.name)) and m[1] == m[2]]

2. Normalise the two layouts

Newer files use datetime, strike_price, right, open_interest, expiry_date. Older ones use timestamp, strike, option_type, oi, expiry. Map both to one set:

import pandas as pd

RENAME = {"datetime": "ts", "timestamp": "ts", "strike_price": "strike",
          "right": "cp", "option_type": "cp", "open_interest": "oi",
          "expiry_date": "expiry"}

def load(f):
    d = pd.read_parquet(f).rename(columns=RENAME)
    d["cp"] = d["cp"].astype(str).str[0].str.upper().map({"C": "CE", "P": "PE"})
    d["expiry"] = pd.to_datetime(d["expiry"], dayfirst=True).dt.normalize()
    return d[["ts", "strike", "cp", "open", "high", "low", "close", "volume", "oi", "expiry"]]

exp = pd.concat(map(load, files), ignore_index=True)

3. Add minutes to the close

close_time = exp["expiry"] + pd.Timedelta(hours=15, minutes=30)
exp["mins_left"] = (close_time - exp["ts"]).dt.total_seconds() / 60

Bars are labelled at their start in IST, so the 15:29 bar shows one minute left, and a bar stamped 15:30 shows zero.

4. Or skip pandas and query with DuckDB

DuckDB reads the Parquet files in place, which is handy when a pack is larger than your memory:

import duckdb
con = duckdb.connect()
con.sql(r"""
  SELECT strike_price, "right", max(volume) AS peak_minute_volume
  FROM read_parquet('data/**/*_OPT_1min_*.parquet', filename = true)
  WHERE regexp_extract(filename, '_OPT_1min_(\d{4}-\d{2}-\d{2})', 1)
      = regexp_extract(filename, '_exp(\d{4}-\d{2}-\d{2})', 1)
  GROUP BY ALL ORDER BY peak_minute_volume DESC LIMIT 20
""").show()

Use one layout per query, or run the query separately on old and new files and union the results after renaming.

5. Save it once

Write the result to a single Parquet file partitioned by expiry. Every later study, from straddle decay to pinning tests, starts from that one table instead of thousands of files.

more guides

One-second or one-minute bars for the last hour of expiry?Does NIFTY pin to max pain on expiry day? Testing it with open interest by strikeFar out-of-the-money options on expiry day: measuring the late spikes