Sikder Huq
quant · data engineering · series part 3

How a 15-Second Bar-Labeling Bug Changed Two Years of Backtests

A pandas resampling error shifted every 15-second bar. Lag correlation exposed the problem.

Jan 3, 2026 · 9 min read

My research runs on raw NQ futures trades and L1/L2 order-book updates, about 38 GB compressed. I had spent real effort making that pipeline fast, and much less validating what it produced.

Part 1: A fast cache, assumed correct

The pipeline is straightforward: unpack the raw archives once, build OHLCV bars at several resolutions, and store each derived dataset in Parquet. Backtests read the compact bar files, never the tick archives. Every cache can be rebuilt from immutable raw data.

Rebuildability matters because a fast cache can still be wrong. I describe the pipeline and its performance in the data-engineering post. This post focuses on a correctness bug in that cache.

Part 2: The $31K discrepancy

I validate everything against a second, independent source: TradingView exports of the same instrument. The same market should produce the same bars.

The first comparison looked reasonable. The median close difference was about 2 points on a 20,000-point index, and bar-to-bar return correlation was 0.36. I told myself the feeds just differed slightly. That explanation was convenient, and I never tested it.

Then the same strategy, on the same 10-day window, made +$28.9K on TradingView data and lost $2.3K on mine. Feeds differ. They don't differ by $31K.

Lag correlation found the shift

I compared returns at several time offsets:

a = mt_bars["close"].diff()      # my tick-derived 15s bars
b = tv_bars["close"].diff()      # TradingView 15s bars, same timestamps
for lag in (-2, -1, 0, 1, 2):
    print(lag, a.corr(b.shift(lag)))

Output: correlation at lag 0 was 0.36. At lag +1 it was 0.60.

A jump from 0.36 to 0.60 at lag +1 meant my bars were shifted by exactly one bar: the bar labeled 10:00:15 held the trades TradingView put at 10:00:00.

The cause was a single resample argument. My 15-second bars had been built from 5-second bars with pandas' label='right', meaning each bar was stamped with its close time. The entire downstream pipeline (and TradingView) assumes bars are stamped with their open time. Every 15-second bar, and every higher-timeframe bar built on top of them, was mis-aligned by one bar.

I shifted the labels back 15 seconds and reran the comparison. The result changed from -$2.3K to +$28.7K, close to TradingView's +$28.9K. A smaller mismatch remained.

A second resampling error

After the label fix, the feeds still differed by about 1.5 points at the median. I rebuilt the bars from the 5-second source with each binning convention and compared every result with TradingView:

bars = ticks_5s.resample("15s", label="left", closed="left").agg(
    {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
)

With label='left', closed='left', the result against TradingView over a 44,000-bar window was:

median |close difference| = 0.00 points. 95th percentile = 0.00 points. Return correlation = 0.999.

The rebuilt bars matched the reference. The original cache had a second bug: closed='right' assigned each boundary 5-second bar to the previous 15-second interval. Changing the label fixed the timestamps but not the interval contents.

Two arguments were wrong. label= controls the timestamp, while closed= controls which observations belong to the interval. Both must match the downstream convention.

Part 3: What two years of results were worth

After rebuilding the cache correctly, I re-ran the full two-year history for several strategy configurations:

The market, engine, and costs were unchanged. Only bar construction changed. I now read the size of the move as a stress test: if a strategy's result depends on which side of a bar boundary a timestamp lands, its edge may be below the noise floor. The structural strategy barely noticed the fix, while the high-frequency variants moved by two orders of magnitude more.

The audit also answered two other questions:

The checklist I now run on every bar cache

The two bugs cost days of rework and invalidated conclusions I had already written down. The diagnostic that finally caught them was a handful of lines of code. I should have run it two years earlier.