Pitfalls & Bugs Found¶
This page documents every significant look-ahead leak, data bug, modelling mistake, and infrastructure failure found during Tessera's development. It is maintained as a first-class document because the ability to systematically find and fix subtle financial ML bugs is the hardest part of building a production trading system.
1. Look-Ahead Leakage via shift(0)¶
Severity: Critical — inflated backtest Sharpe by ~0.4
Phase found: 5 (feature engineering)
File: src/tessera/features/microstructure.py
What happened¶
An early version of OrderFlowImbalance computed the ask-side delta using
the current bar's ask size directly:
This is correct semantically (we want the change in ask size), but
prev_ask_s = ask_s.shift(1) was being computed after the feature
already referenced ask_s without a shift. The net effect was that the
OFI signal at bar \(t\) contained information from bar \(t\) — a one-bar
look-ahead.
Why it was hard to find¶
The bug only inflated Sharpe in the backtest by about 0.4 units — a plausible improvement from a "good" new feature. Standard eyeball review of the formula looked correct. The issue was caught by a Hypothesis property test:
@given(ohlcv_df())
def test_ofi_no_look_ahead(df):
feat = OrderFlowImbalance()
result = feat.compute(df)
shifted = feat.compute(df.shift(1))
# Result at bar t should equal shifted result at bar t+1
assert_series_equal(result.iloc[1:].reset_index(drop=True),
shifted.iloc[1:].reset_index(drop=True), ...)
The test failed, exposing the leak.
Fix¶
All features now call .shift(1) on any cross-bar computation involving
the previous value, and the final feature value for bar \(t\) uses only
data from bars \(0, \ldots, t-1\).
# Fixed
prev_ask_s = ask_s.shift(1)
delta_ask = ... # now uses prev_ask_s, not raw ask_s at current bar
Lesson: Property-test every feature with the shift-invariance test before adding it to the pipeline. A Sharpe improvement of 0.3–0.5 from a "new feature" is a red flag that warrants a look-ahead audit.
2. Bar Aggregation Boundary Error¶
Severity: High — 1-bar look-ahead in all features at the 5-min level
Phase found: 6 (backtest integration)
File: src/tessera/data/store.py, feature pipeline aggregation
What happened¶
When aggregating 1-min bars to 5-min bars:
# BUG: closed="right" means bar labelled T includes the close from T to T+1
df.resample("5min", closed="right", label="right").agg(...)
The bar labelled 10:05:00 included the 1-min candle closing at 10:05:00,
which in Pandas semantics means the bar's data ran from 10:00 to 10:05.
However, the bar was being used as if it closed at 10:00, effectively
incorporating one bar of future prices into the signal computation.
Why it was hard to find¶
The Sharpe inflation was small (≈0.15) and the absolute timestamps looked reasonable on inspection. The bug was found during a manual audit of the bar alignment when we noticed signals correlated too well with the next bar's return.
Fix¶
# Fixed: closed="left" means bar T covers [T, T+Δt) — excludes T+Δt
df.resample("5min", closed="left", label="right").agg(...)
Added a regression test that verifies each 5-min bar's close equals the last 1-min close before the bar's label timestamp.
3. Meta-Model Trained on In-Sample Predictions¶
Severity: High — inflated meta-model precision by 8–12 pp
Phase found: 7 (meta-labeling)
File: src/tessera/models/meta_model.py
What happened¶
The meta-model was initially trained on the primary model's predictions over the entire training set, not only the out-of-sample predictions.
In-sample predictions from a well-trained LightGBM model are overfit: the model has memorised many training examples and its in-sample probability estimates are overconfident. The meta-model learned to trust these overconfident estimates, producing a meta-model that looked precise in training but failed to generalise.
Symptom¶
Meta-model in-sample precision: 68 %. OOS precision: 51 % (near random). The precision gap gave a false sense of the meta-model's usefulness.
Fix¶
The meta-model is now trained exclusively on the primary model's out-of-sample predictions from each purged fold:
# Collect OOS predictions from each fold
oos_preds = []
for fold_train, fold_test in purged_kfold.split(X, y, t_events):
primary.fit(X.iloc[fold_train], y.iloc[fold_train])
oos_preds.append(primary.predict_proba(X.iloc[fold_test]))
# Meta-model trains on OOS preds only
X_meta = np.vstack(oos_preds)
meta.fit(X_meta, y_meta)
OOS precision after fix: 61.4 %. Improvement of +0.13 Sharpe from meta-labeling.
4. Slippage Underestimation During High-Volatility Bars¶
Severity: Medium — live degradation of ~0.18 Sharpe vs backtest
Phase found: 11 (paper trading)
File: src/tessera/backtest/slippage.py
What happened¶
The square-root impact model was calibrated on average-volatility days. During the first 30 minutes of a volatility spike — identified as VolOfVol crossing its 95th percentile — actual slippage was approximately 2× the model estimate.
During the LUNA, FTX, and USDC stress windows, this caused the model to significantly underestimate execution costs during the most important periods (when the strategy most needed to flatten positions quickly).
Fix¶
Added a volatility regime multiplier:
vol_of_vol_pct = (vol_of_vol_series.rank(pct=True).iloc[-1])
slippage_mult = 2.0 if vol_of_vol_pct > 0.95 else 1.0
position_scale = 0.5 if vol_of_vol_pct > 0.95 else 1.0
When VolOfVol is elevated: slippage estimate doubles, position size halves. This reduced the backtest Sharpe by 0.05 (it was artificially inflated before) but improved the backtest-to-paper gap.
5. HMM State Instability at Regime Boundaries¶
Severity: Low–Medium — noisy gating signal, intermittent over-trading
Phase found: 10 (risk stack)
File: src/tessera/features/regime.py, src/tessera/strategies/ml_directional.py
What happened¶
At regime transitions, the HMM Viterbi path would switch states rapidly for 5–10 bars before settling. This caused the gating signal to alternate between "trade" and "block" on consecutive bars, triggering unnecessary round-trips and inflating transaction costs.
Fix¶
Require the HMM posterior probability to exceed 0.70 for a full bar before acting on a regime change:
# Only gate if crash state has been confirmed for one full bar
if hmm_probs["crash"] > 0.70 and hmm_probs_prev["crash"] > 0.70:
self._regime_gate = "crash"
This added a one-bar lag but eliminated the oscillation, reducing unnecessary round-trips by ~85 % at regime boundaries.
6. DuckDB View Staleness After Ingest¶
Severity: Low — caused stale features on the first bar after ingest
Phase found: 8 (backtest + feature caching)
File: src/tessera/data/store.py
What happened¶
The DuckDB in-memory connection registered Parquet files as views at startup.
After backfill_ohlcv() wrote new Parquet files to disk, the DuckDB views
still pointed to the pre-ingest snapshot and did not see the new data.
This caused the feature pipeline to compute features on stale data for the first run after any ingest operation.
Fix¶
After every ingest write, call duckdb_connect() again to recreate the
views from the updated Parquet directory:
def backfill_ohlcv(...):
...
write_parquet(df, ...)
_refresh_duckdb_views() # invalidate cached connection
7. CCXT Pagination Gap Under High Load¶
Severity: Low — rare data gaps in ingest
Phase found: 2 (data ingestion)
File: src/tessera/data/ingest_ohlcv.py
What happened¶
Under high exchange API load, CCXT's fetch_ohlcv would return pages with
missing bars at pagination boundaries. The incremental ingestor did not
detect these gaps and would happily write a Parquet file with missing bars,
which then propagated as NaN features downstream.
Fix¶
Added a gap check after each page fetch:
expected_timestamps = pd.date_range(start, end, freq="1min")
missing = expected_timestamps.difference(df.index)
if len(missing) > 2:
logger.warning("ohlcv_gap_detected", count=len(missing), retrying=True)
# Re-fetch the missing window
8. Funding Rate Leakage via Forward-Fill¶
Severity: Low — subtle 8-hour look-ahead in carry sleeve
Phase found: 6 (feature engineering audit)
File: src/tessera/features/funding.py
What happened¶
The funding rate is published every 8 hours. When forward-filling to bar
frequency, the rate at 10:00:00 was being used for bars at 09:55:00 —
filling backwards by accident because of a Pandas fillna(method="ffill")
applied to an unsorted index.
Fix¶
Sort by timestamp before forward-filling; add a shift(1) after
forward-filling so the funding rate used at bar \(t\) is the rate
announced before bar \(t\).
Meta-lesson: The Look-Ahead Audit Checklist¶
Every new feature or data transformation should be verified against:
- Shift test: Does
compute(df).shift(1) == compute(df.shift(1))? (Hypothesis property test) - Bar alignment: When aggregating from finer to coarser bars, does the coarser bar's label refer to data before or at the label timestamp?
- In-sample / OOS split: Is any model being trained on predictions that were made in-sample by another model? (Meta-model leak)
- Forward-fill direction: Is
ffillapplied to a sorted index? - DuckDB view freshness: Are views refreshed after any write?
Running make test covers property tests for all five checks on every
feature currently in the pipeline.