QuantConnect / QuantConnect/Lean

OptionFilterUniverse Expiration is confusing and inefficient

Open
#8,932 3 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug depth
Dominant language
C#
Stars
21.7k
Forks
5.3k
Avg merge
2d 22h
Merged PRs (30d)
34

Description

Expected Behavior

When filtering options with OptionFilterUniverse.Expiration() the documentation says "minExpiry: The minimum time until expiry to include, for example, TimeSpan.FromDays(10) would exclude contracts expiring in less than 10 days".

Actual Behavior

Looking at the example below, when using OptionHistory, the expiration filter shows every option with an expiry between start + minExpiry and end + maxExpiry. The "days until expiration" in the usual meaning for options are not restricted at all. Even if this behaviour is intended, it is confusing. Also, the filter is not efficient given the huge amount of data for option chains with daily expiries, e.g. SPXW. With the current behaviour, the number of expiries for SPXW over a time period of n trading days is quadratic in n.

Potential Solution

The behaviour of the OptionFilterUniverse expiration filter could be changed or extended with a parameter such that the 'dte' column in the example code is between minExpiry and maxExpiry. At the very least, the documentation should be clarified.

Reproducing the Problem

research.ipynb with 'Foundation-Py-Default' Kernel in masterv17255.

import datetime as dt
qb = QuantBook()
spx_symbol = qb.add_index('SPX', Resolution.DAILY).symbol
option = qb.add_index_option(spx_symbol, 'SPXW', Resolution.DAILY)

first_date = dt.date(2025, 8, 4) # monday
qb.set_start_date(first_date + dt.timedelta(days=10)) # somewhere after the last expected expiry
option.set_filter(lambda u: u.weeklys_only().calls_only().expiration(1, 2).strikes(-1, 1))
df = qb.option_history(option.symbol, first_date, first_date + dt.timedelta(days=3), Resolution.DAILY).data_frame.reset_index()

df['expiry'] = pd.to_datetime(df['expiry'])
df['date'] = df['time'].dt.normalize()
df['dte'] = df['expiry'] - df['date']
df.groupby(['date', 'expiry', 'dte'])['close'].count().rename('strike_count').reset_index()

Result: DTE is not between 1 and 2, but between 0 and 4.

 date     expiry        dte      strike_count
2025-08-04 2025-08-05 1 days             2
2025-08-04 2025-08-06 2 days             4
2025-08-04 2025-08-07 3 days             4
2025-08-04 2025-08-08 4 days             4
2025-08-05 2025-08-05 0 days             2
2025-08-05 2025-08-06 1 days             4
2025-08-05 2025-08-07 2 days             4
2025-08-05 2025-08-08 3 days             4
2025-08-06 2025-08-06 0 days             4
2025-08-06 2025-08-07 1 days             4
2025-08-06 2025-08-08 2 days             4

I will not write a second ticket and possibly the behaviour is intended, but i like to mention that the strikes filter also behaves weirdly in this example.

df = df.sort_values(['date', 'expiry', 'dte', 'strike'])
mask = df['dte'] == dt.timedelta(days=4)
df[mask][['date', 'expiry', 'dte', 'strike', 'type', 'symbol']]
--->
    date     expiry    dte  strike  type                    symbol
2025-08-04 2025-08-08 4 days  6295.0  Call  SPXW YUSFHNNH0WA6|SPX 31
2025-08-04 2025-08-08 4 days  6300.0  Call  SPXW YUSFGV0TFAUM|SPX 31
2025-08-04 2025-08-08 4 days  6345.0  Call  SPXW YUSFHNVQPMR2|SPX 31
2025-08-04 2025-08-08 4 days  6350.0  Call  SPXW YUSFGXNEWLXQ|SPX 31
Checklist
  • I have completely filled out this template
  • I have confirmed that this issue exists on the current master branch
  • I have confirmed that this is not a duplicate issue by searching issues
  • I have provided detailed steps to reproduce the issue

Probably, the issue has been discussed before, but i could not find anything and the example code is quite useful for further discussion in my opinion.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running research.ipynb with the Foundation-Py-Default kernel and reviewing OptionFilterUniverse.Expiration through the linked ContractSecurityFilterUniverse documentation. Compare the observed DTE and strike results with the intended OptionHistory behavior. Done means the expiration semantics are agreed and either corrected or clearly documented, with the reproduction reflecting the expected bounds.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp, jupyter-notebook, python
Domain
backend, data, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.