← All posts
11 min read

Expiry Indexing: The Off-by-One That Silently Deleted 96% of My Trades

#quant#backtesting#options#nifty#expiry
📑 On this page

Part 3 of Quant Engineering. This is lesson 1 in the Options Mechanics track.

Nothing in this series is trading advice. It is engineering: how to build and validate the machinery that measures strategies, and how that machinery fails.

The decision

Every multi-leg options strategy definition has to answer a deceptively simple question: which expiry does this leg trade? The near-universal answer is an index — "expiry 1," "the nearest weekly," "expiry 2 for the hedge leg." And the moment a number stands for a contract, someone has chosen a convention: does counting start at zero or at one?

This post is about the week I lost to that question, and about the general engineering method the week paid for. The short version: I had more than ten thousand real strategy configurations exported from a retail backtesting platform, each leg specifying its expiry as a small integer. My engine, written by a programmer with a programmer's reflexes, treated that integer as a zero-based list index. The platform meant it as one-based. Nothing crashed. Nothing warned. The engine simply selected the wrong expiry for essentially every leg, failed to find the strikes the strategy asked for, skipped almost every intended trade, and reported the runs as successful.

Roughly 96 percent of strike selections failed silently. One representative strategy produced 7 nonsensical trades before the fix and 261 coherent ones after it — a one-line change.

The naive version

The setup, simplified but faithful. Each strategy config arrives as JSON. A leg looks like this:

{
  "instrument": "NIFTY",
  "direction": "SELL",
  "optionType": "CE",
  "strikeRule": "ATM",
  "expiry": "1",
  "quantity": "1",
  "stopLossPct": "30"
}

(Notice, in passing, that every numeric field is a string. That is a different landmine, covered in the data-quality post; coerce with intent.)

The engine, meanwhile, builds the day's available expiries as a sorted list and resolves a leg's contract like any programmer would:

expiries = sorted(chain.expiries_on(trade_date))   # e.g. [2023-08-10, 2023-08-17, 2023-08-31]
 
def resolve_expiry(leg, expiries):
    return expiries[int(leg["expiry"])]            # the bug, hiding in plain sight

leg["expiry"] is "1", int() makes it 1, expiries[1] is the second nearest expiry. The platform meant the first. Whoever wrote the platform counted like a human — expiry 1 is the nearest one, expiry 2 the next — and whoever wrote my resolver counted like a C compiler.

How it breaks — and why it breaks quietly

Here is the part worth internalizing, because it generalizes far beyond expiries: the failure mode of a convention mismatch is not an error, it is a plausible wrong answer. expiries[1] is a perfectly valid expiry. The chain for it exists. The engine proceeds.

What made it catastrophic rather than merely wrong was an interaction with the dataset. My options data — like a great deal of retail-accessible Indian options data — contained bars for each expiry only during its final week of life (the front-week-only trap; next post). So when the engine reached for next week's expiry on a Monday, that contract's bars mostly did not exist yet in the store. Strike selection would scan for an ATM contract with live bars, find nothing usable, and give up on the entry.

The observable symptom was therefore not "wrong trades." It was almost no trades. Runs completed green. Equity curves were flat lines with a handful of blips. Summaries said things like "7 trades in four years" for a strategy configured to enter every single morning.

And this is where the debugging went wrong for days: a near-zero trade count has a dozen innocent explanations. Entry-time filters too strict? Indicator gate never firing? Stop-loss instantly triggering? Data holes? I investigated each. The actual cause — every leg reaching for a contract one week too far away — was invisible in any single trade's log, because each skipped entry looked like a routine "no matching contract" data condition. The engine's politeness was the problem: it treated systematic failure as a scattering of individually unremarkable skips.

The verification method: ask the data, not the docs

The fix is one line. The lesson is a method, and the method is the reason this post exists.

The platform's documentation did not clearly state its indexing convention (documentation rarely documents the things authors consider obvious). But the corpus of configs stated it beyond any doubt. I ran a distribution over every leg in the archive:

from collections import Counter
 
legs = [leg for cfg in configs for leg in cfg["legs"]]
Counter(int(leg["expiry"]) for leg in legs)
# Counter({1: 6449, 2: 41, 3: 12, 4: 3})   — and zero occurrences of 0

Six and a half thousand legs. Not one uses index 0. Under a zero-based reading, that distribution asserts that thousands of retail intraday strategies — straddle sellers, expiry-day scalpers — deliberately trade next week's expiry and literally no one trades the front week. That is absurd on its face; front-week weeklies are where Indian index options activity concentrates. Under a one-based reading the distribution says: almost everyone trades the nearest expiry, a few hedge into later ones. Perfectly sensible.

The data answered a question the documentation would not. That is the general method: when two conventions are possible, find the interpretation under which the observed distribution is sane. It works everywhere conventions hide:

  • Timestamp labels: if bars labeled 15:29 exist but 15:30 never does, labels are bar-open times.
  • Strike rules: if "ATM plus 2" configs cluster around premiums consistent with strikes 100 points out, the unit is grid steps, not points or percent.
  • Percent fields: if stop-loss values are mostly 20 to 50, the unit is percent-of-premium; if they are mostly 0.2 to 0.5, it is a fraction. A field full of 30s read as a fraction would mean a 3,000 percent stop — insane, therefore wrong.

Sanity of the induced distribution is the test. Documentation is a hypothesis; the corpus is the evidence.

The same bug wearing other coats

Once you have been bitten, you start seeing the expiry off-by-one's siblings everywhere, and it is worth naming the family so your config parser interrogates every member on arrival.

Strike offsets are the closest cousin. A config saying the call leg is "ATM+3" might mean three grid steps (150 points on a 50-point grid), three percent of spot (nearly 700 points with NIFTY in the twenty-two thousands), or literally 3 points (which no grid supports — a tell in itself). The three readings select contracts hundreds of points apart with utterly different premiums and Greeks; the distribution method resolves it in minutes by checking which reading lands legs on strikes that exist and premiums that make sense for the strategy type.

Direction encodings look too simple to get wrong, which is exactly the danger: "transactionType": "S" versus "SELL" versus 1-means-buy versus 1-means-sell. Read it backwards and every straddle seller becomes a straddle buyer — and here is the nasty part: the backtest still runs beautifully. Premiums are paid instead of collected, thetas flip sign, and the aggregate P&L distribution inverts, which on a corpus of short-volatility strategies produces results that are not obviously insane, merely consistently wrong. Direction deserves its own distributional check: retail index-options corpora skew heavily toward net premium selling, and a parse that makes 80 percent of configs net buyers is telling you about your parser, not the users.

Quantity units round out the family: lots versus contracts. NIFTY's lot size has changed over the years — 75, 50, 25, then back up — so a "quantity 2" config means a different number of contracts depending on period and on whether the field was ever in lots at all. Get the unit wrong and every P&L number in the archive is off by a clean, plausible-looking multiple.

The pattern across all four: the wrong reading never crashes, always produces internally consistent output, and is detectable in an hour by asking which interpretation makes the corpus sane. Budget that hour per field, at the boundary, once.

The correct version

Mechanically, the fix is trivial. Architecturally, it deserves more respect than an inline - 1:

def resolve_expiry(leg, expiries):
    """Platform convention: expiry '1' = nearest listed expiry (1-based).
 
    Verified empirically against the config corpus (6,449 of 6,505 legs
    use index 1; zero legs use index 0). Do not 'fix' this to 0-based.
    """
    idx = int(leg["expiry"]) - 1
    if idx < 0 or idx >= len(expiries):
        raise ExpiryResolutionError(
            f"leg requests expiry {leg['expiry']!r} but only "
            f"{len(expiries)} expiries are listed on this date"
        )
    return expiries[idx]

Three deliberate choices here. The translation happens once, at the boundary — everything inside the engine speaks zero-based Python, and only the config parser knows the platform dialect; scattering - 1 through the codebase recreates the ambiguity in every file. The comment records the evidence, not just the rule, because a future maintainer (me, four months later) will otherwise see - 1, smell an off-by-one, and helpfully remove it. And resolution failure raises, because the engine's original sin was continuing politely past systematic impossibility.

The fix then needs a tripwire so the class of bug cannot return: an aggregate invariant on every batch run. After the patch, entry-attempt success rates jumped from a few percent to the high nineties. So assert something like it:

if run.entry_attempts > 50 and run.entry_success_rate < 0.5:
    raise EngineSanityError(
        f"{run.config_id}: only {run.entry_success_rate:.0%} of entries "
        "resolved a contract — convention mismatch or data hole?"
    )

A strategy that tries to enter every day and succeeds 4 percent of the time is not a quiet market; it is a broken engine. Make the engine say so.

Edge cases the fix must survive

One-based translation is the beginning, not the end, because "the nearest expiry" is itself a moving target.

Expiry day itself. At 9:20 on expiry morning, is the expiring contract still expiry 1? (Yes — it trades until 15:30.) But a config entering at 15:25 with a positional holding intent effectively cannot mean it. Platforms differ on whether same-day expiries stay in the index or roll at some cutoff; my engine keeps the expiring contract as index 1 through the close, because that matches both the config corpus's expiry-day strategies and the platform's observed behavior. Whatever you choose: write it down, test it with a fixture dated on an expiry Thursday.

Holiday-shifted expiries. When the scheduled expiry day is a holiday, settlement moves to the prior session — my dataset contains a lone Wednesday expiry in April 2024 sitting in a four-year sea of Thursdays. An engine that computes "nearest expiry" from a weekday rule instead of the derived calendar disagrees with reality on exactly those weeks. Index conventions and calendar derivation compound: get either wrong and the week is garbage.

Weekly–monthly overlap. In the week where the monthly contract is the nearest expiry, a config that says "expiry 1 for income leg, expiry 2 for hedge" changes character: the hedge lands on next week's weekly rather than a distant monthly. That is correct resolution of the letter of the config — but it is why per-week trade behavior can shift on overlap weeks, and why validation comparisons should segment those weeks separately.

Sparse chains. Early in the dataset, or after data holes, the day may list fewer expiries than a leg requests. The raise above handles it; the temptation to "fall back to the last available expiry" reintroduces polite wrongness. A leg that wants expiry 4 in a store that only ever holds front-week data should fail loudly at config validation time — before the run — which is the coverage-audit topic the next post takes up.

Check your own engine

  • Is every external index convention (expiry, strike offset, leg ordering) translated exactly once, at the parsing boundary, with the evidence documented beside the translation?
  • Have you verified each convention against a distribution over real inputs rather than documentation?
  • Does contract-resolution failure raise, rather than skip?
  • Do batch runs assert aggregate invariants — entry success rates, trades-per-config distributions — that would catch a systematic mismatch?
  • Do you have a test fixture pinned to an expiry day, and one pinned to a holiday-shifted expiry week?
  • If someone re-based your expiry indexing tomorrow, which test fails first? (If the answer is "none," write that test today.)

Where this goes next

The expiry bug was half of a compound failure. The other half was the dataset itself: the reason expiries[1] had no usable bars on a Monday is that the store only ever contained each contract's final week of life. That constraint — front-week-only options data — is common, rarely advertised, and determines which strategies you can honestly backtest at all. The next post builds the audit that detects it and the feasibility classifier that fences your strategy universe to what the data can actually answer.