Lookahead Audit
auditLookahead Audit — Master-LGBM Feature Panel (May 2026)
2026-05-19 · triggered by the B4 finding (ETF-Flow Exp #18 had a lookahead bias)
Trigger
Exp #18 (ETF-Flow Event Study) reported IC = +0.37 and was "promoted". But the residual check Exp #18b (ETF-Flow Residual) showed:
- With the correct 22:00-UTC marker, the original IC shrinks to +0.044
- Same-day-confound IC = +0.405 (devastatingly high — flow_t and ret_t on the same day correlate strongly, because the ETF-flow data releases reflect the movement ON THE SAME DAY)
- Residualised: IC +0.075, CI includes zero
Root of the bias in Exp #18: Daily resampling with label="right", closed="right" sets the close-bar timestamp to "midnight of the next day". So when flow_t at "2024-01-11 00:00" is aligned with fwd_ret_1d.shift(-1), it shifts onto the BTC return of the same trading day (2024-01-11), not the day that follows. But the flow is only reported at 22:00 UTC of that day → classic lookahead.
Consequence for the other experiments: check all of them for whether the feature construction is causal. The Master-LGBM is the most important piece.
Master-LGBM feature-by-feature audit
| # | Feature | Native granularity | Native timestamp | Information physically available | Applied shift | Actual delay in the panel | Causal? |
|---|---|---|---|---|---|---|---|
| 1 | ret_1h, ret_4h, ret_24h, ret_7d |
1h | right-closed bar end | immediately at bar end | none (use close ≤ t) | 0h | ✅ |
| 2 | log_rv_1h_ann, log_rv_4h_ann, log_rv_1d_ann, log_rv_7d_ann |
1m → 1h via .last() | right-closed | immediately at bar end | none | 0h | ✅ |
| 3 | log_vol, vol_z_1d, range_4h |
1h aggregate | right-closed | immediately | none | 0h | ✅ |
| 4 | hour_sin, hour_cos, dow |
timestamp-derived | n/a | immediately | none | 0h | ✅ |
| 5 | funding_rate, funding_z_30d, funding_cum_1d |
8h Hyperliquid | 00:00, 08:00, 16:00 UTC | immediately at settlement | ffill onto 1h panel | 0–7h (bar position in the settlement window) | ✅ |
| 6 | bocpd_p_short |
15m posterior | bar end | immediately (forward filter by construction) | ffill onto 1h | 0–14 min | ✅ |
| 7 | hmm_p_state1 |
1h | per walk-forward split | only train data in the HMM fit, then causal forward filter | refit per split | 0h | ✅ |
| 8 | iv_ann (DVOL), vrp |
daily Deribit | 00:00 UTC | immediately (DVOL is a live index) | .ffill().shift(1) ⇒ 1h delay |
1h | ✅ |
| 9 | stablecoin_d7 |
daily DefiLlama | 00:00 UTC | a few hours' delay | .diff(7).shift(1) ⇒ 1d delay |
24h | ✅ |
| 10 | etf_flow |
daily Farside | 00:00 UTC of the flow date | 22:00 UTC of the flow date | .shift(1, freq="D") ⇒ flow_t shifted to d+1 00:00 UTC |
2h | ✅ |
| 11 | dxy_z_4h |
yfinance 1h | bar end | yfinance has ~15min delay for the free tier | no additional shift | ~15 min | ⚠️ small delay not modelled |
| 12 | target_log_rv_fwd4h (LABEL) |
4h forward | .shift(-4) on rv_4h_1h |
forward by definition | shift(-4) on rv_4h_1h | -4h (forward) | ✅ Label |
Finding: all features are causal. The only minor note:
- dxy_z_4h: The yfinance free tier has a ~15min publication delay. If we go live trading, we would have to either add an extra .shift(1) or use a paid real-time source. For a backtest on historical yfinance data the delay is irrelevant, because the values are written retroactively as they were available after the 15 minutes — i.e. the backtest is more conservative than reality.
Why Exp #18 was flawed
Verbose reconstruction of the bias:
flows index: flow_t @ 2024-01-11 00:00 UTC (was reported ~22:00 UTC that day)
df_1d index: right-closed → close_at_2024-01-12_00:00 = end-of-day 2024-01-11
btc_ret_1d: log(close_2024-01-12_00:00) - log(close_2024-01-11_00:00) = return DURING 2024-01-11
fwd_ret_1d.shift(-1) at index 2024-01-11_00:00 → btc_ret_1d at 2024-01-12_00:00 = return DURING 2024-01-11
^^^^^^^^^^^^^^^^^^^^^^
same day as flow_t!
In words: for flows_total at "2024-01-11 00:00", the alignment is onto fwd_ret_1d at the same index, which is the return realized during 2024-01-11. But flow_t only became known at 22:00 UTC of that day — the model would effectively have looked into the future by labeling a return as "forward" that was already 90% complete before the feature even existed.
Corrections / consequences
Verdict updates
| Exp | Old | New | Rationale |
|---|---|---|---|
| #18 ETF-Flow Event Study | ⭐ promoted (IC +0.37) | ❌ dropped (lookahead) | Same-day confound, the correct setup gives IC +0.04 |
| #18b ETF-Flow Residual | n/a | ❌ dropped (residual IC +0.075, CI⊃0) | Audit experiment |
| #22 Master-LGBM | ⭐ promoted (+10.6 pp R²) | ⭐ promoted (validated) | All features causally verified. The ETF feature is not in the top-3 importance → the lift comes from log_rv_7d_ann + iv_ann |
Synthesis update
4 promotes becomes 3 promotes + 1 lookahead drop:
- ⭐ BOCPD p_short (Exp #15)
- ⭐ DVOL/VRP (Exp #19)
- ⭐ Master-LGBM (Exp #22, validated)
- ❌ ETF-Flow Event Study (Exp #18, lookahead bias found)
- 🟡 HMM regime (Exp #16, pursue)
- 🟡 Stablecoin Δ7d (Exp #17, weak)
- 🟡 DXY shock (Exp #20, weak)
- ❌ HAR-RV-J (Exp #13)
- ❌ VPIN (Exp #14)
Master-LGBM stays promoted — but why?
The ETF feature has a 1-day shift in the Master-LGBM (flow_d.shift(1, freq="D")) and is therefore causal. Since Exp #18 itself gives no lift (residual IC +0.07), the ETF feature will be far down in the LGBM importance ranking (not in the top-3). The R² lift of +10.6 pp comes from:
log_rv_7d_ann(top-1 feature) — a vol-persistence extension beyond the HAR-RV cascade (HAR-RV uses 1h/4h/1d; the LGBM additionally gets 7d).iv_ann(top-2) — DVOL brings implied vol as an orthogonal information source. This is the VRP insight from Exp #19.hour_cos(top-3) — vol has a clear intraday pattern; LGBM uses it nonlinearly better than HAR-RV without a time feature.
These three are all causally clean. The ETF feature could even slightly hurt (noise). A robustness variant without ETF would be a sensible validation — see Next Steps.
Recommended follow-up audits
These audits are still open for the other promotes:
BOCPD (Exp #15) — how causal is the z-score?
The BOCPD input is z = r / rolling_std(r, 96 bars). The rolling-std uses past bars — causal. But: r.rolling(96, min_periods=48).std() with the default pandas settings — is that really causal (backward only) and not somehow centered? .rolling(N) is right-aligned by default (label = right edge), so it uses only the last N values. ✅ Causal.
VRP (Exp #19) — DVOL timing exactly
DVOL daily close stamped 00:00 UTC. We ffill onto the 4h panel without an explicit shift. That means: at bar 2024-01-11 00:00, DVOL = today's daily close. But that is only available at 00:00 UTC. If we build a prediction for the NEXT 4h vol (i.e. vol from 00:00-04:00), we use a DVOL that just "closed". Tight but okay — the information is physically live.
In the Master-LGBM we use .shift(1) for a 1h lag — more conservative. ✓
Stablecoin (Exp #17) — sc_daily timestamp semantics
When exactly does DefiLlama publish the totalCirculatingUSD for "2024-01-11"? The API has no explicitly documented release time. Conservative: the 1d shift in the Master-LGBM is sufficient.
Check walk-forward embargo
walk_forward_splits(embargo_minutes=max(1440, 4*60)) is 1440 min (1 day), which is sufficient for a fwd-4h target. But for fwd-1d targets the embargo would have to be ≥ 1d. In the Master-LGBM the target is fwd-4h, so 1440 is enough ≥ 4h.
In Exp #15 BOCPD: embargo_minutes=max(1440, 4*60) = 1440 min, target is fwd-4h. ✓
In Exp #18 ETF-Flow: embargo_minutes=1440 with target fwd-1d. We would need ≥ 1d embargo. It is exactly 1d → borderline but OK.
Lessons Learned
-
Check pandas resampling semantics.
label="right", closed="right"on a daily aggregation packs an entire day into the midnight-of-the-FOLLOWING-day label. That is intuitive, but if you then doshift(-1), you land in the past. With non-trivial data sources (e.g. Farside), always reason explicitly about the release time, don't blindly take over the parquet timestamp. -
Recognise the same-day confound. Every time a feature and a label occur on the same day (even if the label is labeled as "forward" in the DataFrame), check for confound. Standard test: measure the feature_t → ret_pre_t IC. If that is high, cross-check with residualisation.
-
Causal forward filter ≠ smoothing. With HMM/BOCPD features, the choice between a forward-only filter and backward smoothing is the most common lookahead spot. We use strictly forward — good.
-
Feature importance helps with the sanity check. If a presumed edge feature (ETF) does not land in the top importance in the Master-LGBM, that is a hint that the standalone IC may have been confound.
Action Items
- ✅ Update the synthesis doc — ETF-Flow as ❌ instead of ⭐
- ⏳ Master-LGBM validation without the ETF feature: expected R² lift identical or slightly higher → confirms that ETF does not contribute materially
- ⏳ For future experiments: before
dropna()andshift(), create a trace plot (plot feature value + label value over time with annotations for release times) - ⏳ Set the embargo in
walk_forward_splitsfor 1d targets to 2d (safety buffer)