Lookahead Audit
auditLookahead-Audit — Master-LGBM Feature Panel (Mai 2026)
2026-05-19 · ausgelöst durch B4-Finding (ETF-Flow Exp #18 hatte Lookahead-Bias)
Anlass
Exp #18 (ETF-Flow Event Study) hat IC = +0.37 gemeldet und wurde "promoted". Der Residualcheck Exp #18b (ETF-Flow Residual) hat aber gezeigt:
- Mit korrektem 22:00-UTC-Marker schrumpft Original-IC auf +0.044
- Same-day-confound IC = +0.405 (verheerend hoch — flow_t und ret_t auf demselben Tag korrelieren stark, weil ETF-Flow-Daten Releases AM SELBEN TAG der Bewegung reflektieren)
- Residualisiert: IC +0.075, CI inkludiert Null
Wurzel des Bias in Exp #18: Daily resampling mit label="right", closed="right" setzt den Close-Bar-Stempel auf "Mitternacht des nächsten Tages". Wenn dann flow_t an "2024-01-11 00:00" mit fwd_ret_1d.shift(-1) aligned wird, schiebt das auf BTC-Return der selben Tageshandlung (2024-01-11), nicht auf den darauf folgenden Tag. Flow wird aber erst um 22:00 UTC dieses Tages reportet → klassischer Lookahead.
Konsequenz für die anderen Experimente: alle prüfen, ob die Feature-Construction kausal ist. Master-LGBM ist das wichtigste Stück.
Master-LGBM Feature-by-Feature Audit
| # | Feature | Native Granularität | Native Timestamp | Information physisch verfügbar | Angewandter Shift | Tatsächliche Verzögerung im Panel | Causal? |
|---|---|---|---|---|---|---|---|
| 1 | ret_1h, ret_4h, ret_24h, ret_7d |
1h | right-closed bar end | sofort am Bar-Ende | keiner (verwenden 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 | sofort am Bar-Ende | keiner | 0h | ✅ |
| 3 | log_vol, vol_z_1d, range_4h |
1h aggregate | right-closed | sofort | keiner | 0h | ✅ |
| 4 | hour_sin, hour_cos, dow |
timestamp-derived | n/a | sofort | keiner | 0h | ✅ |
| 5 | funding_rate, funding_z_30d, funding_cum_1d |
8h Hyperliquid | 00:00, 08:00, 16:00 UTC | sofort am Settlement | ffill auf 1h Panel | 0–7h (Bar-Position im Settlement-Fenster) | ✅ |
| 6 | bocpd_p_short |
15m posterior | bar end | sofort (forward filter by construction) | ffill auf 1h | 0–14 min | ✅ |
| 7 | hmm_p_state1 |
1h | per walk-forward split | nur train-Daten in HMM-fit, dann causal forward filter | refit pro Split | 0h | ✅ |
| 8 | iv_ann (DVOL), vrp |
daily Deribit | 00:00 UTC | sofort (DVOL ist Live-Index) | .ffill().shift(1) ⇒ 1h-Delay |
1h | ✅ |
| 9 | stablecoin_d7 |
daily DefiLlama | 00:00 UTC | wenige Stunden Delay | .diff(7).shift(1) ⇒ 1d-Delay |
24h | ✅ |
| 10 | etf_flow |
daily Farside | 00:00 UTC der Flow-Date | 22:00 UTC der Flow-Date | .shift(1, freq="D") ⇒ flow_t verschoben auf d+1 00:00 UTC |
2h | ✅ |
| 11 | dxy_z_4h |
yfinance 1h | bar end | yfinance hat ~15min Delay für free tier | keine zusätzliche Shift | ~15 min | ⚠️ kleine Verzögerung nicht modelliert |
| 12 | target_log_rv_fwd4h (LABEL) |
4h forward | .shift(-4) auf rv_4h_1h |
per Definition forward | shift(-4) auf rv_4h_1h | -4h (forward) | ✅ Label |
Befund: alle Features sind kausal. Die einzige kleinere Anmerkung:
- dxy_z_4h: yfinance free tier hat ~15min Publication-Delay. Wenn wir live trading machen, müssten wir entweder eine zusätzliche .shift(1) einbauen oder eine bezahlte Real-Time-Quelle nutzen. Für Backtest auf historischen yfinance-Daten ist der Delay irrelevant, weil die Werte rückwirkend so geschrieben werden, wie sie nach den 15 Minuten verfügbar waren — d.h. der Backtest ist konservativer als die Realität.
Warum Exp #18 fehlerhaft war
Verbose Rekonstruktion des Bias:
flows index: flow_t @ 2024-01-11 00:00 UTC (was reportet ~22:00 UTC dieses Tages)
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
^^^^^^^^^^^^^^^^^^^^^^
derselbe Tag wie flow_t!
In Worten: bei flows_total an "2024-01-11 00:00" wird auf fwd_ret_1d an demselben Index aligned, was der Return realisiert während des 11.01.2024 ist. Aber flow_t wurde erst um 22:00 UTC dieses Tages bekannt — das Modell hätte praktisch in die Zukunft gesehen, indem es einen Return als "forward" labeled, der bereits zu 90% gelaufen war, bevor das Feature überhaupt existierte.
Korrekturen / Konsequenzen
Verdict-Updates
| Exp | Alt | Neu | Begründung |
|---|---|---|---|
| #18 ETF-Flow Event Study | ⭐ promoted (IC +0.37) | ❌ dropped (lookahead) | Same-day Confound, korrektes Setup gibt IC +0.04 |
| #18b ETF-Flow Residual | n/a | ❌ dropped (IC residual +0.075, CI⊃0) | Audit-Experiment |
| #22 Master-LGBM | ⭐ promoted (+10.6 pp R²) | ⭐ promoted (validiert) | Alle Features kausal verifiziert. ETF-Feature ist nicht in top-3 importance → der Lift kommt aus log_rv_7d_ann + iv_ann |
Synthese-Update
Aus 4 Promotes wird 3 Promotes + 1 Lookahead-Drop:
- ⭐ BOCPD p_short (Exp #15)
- ⭐ DVOL/VRP (Exp #19)
- ⭐ Master-LGBM (Exp #22, validiert)
- ❌ ETF-Flow Event Study (Exp #18, Lookahead-Bias gefunden)
- 🟡 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 bleibt promoted — aber warum?
ETF-Feature hat im Master-LGBM eine 1-Tages-Verschiebung (flow_d.shift(1, freq="D")) und ist damit kausal. Da Exp #18 selbst kein Lift gibt (residual IC +0.07), wird die ETF-Feature im LGBM-Importance-Ranking weit unten sein (nicht in den Top-3). Das R²-Lift von +10.6 pp kommt aus:
log_rv_7d_ann(Top-1 Feature) — Vol-Persistence-Erweiterung über die HAR-RV-Kaskade hinaus (HAR-RV nutzt 1h/4h/1d; LGBM bekommt zusätzlich 7d).iv_ann(Top-2) — DVOL bringt Implied-Vol als orthogonale Informationsquelle. Das ist die VRP-Erkenntnis aus Exp #19.hour_cos(Top-3) — Vol hat einen klaren intraday-Pattern; LGBM nutzt das nonlinear besser als HAR-RV ohne Time-Feature.
Diese drei sind alle kausal sauber. Der ETF-Feature könnte sogar leicht schaden (Rauschen). Eine Robustheits-Variante ohne ETF wäre eine sinnvolle Validierung — siehe Next Steps.
Empfohlene Folge-Audits
Diese Audits sind noch offen für die anderen Promotes:
BOCPD (Exp #15) — wie kausal ist der Z-Score?
Der BOCPD-Input ist z = r / rolling_std(r, 96 bars). Der rolling-std nutzt vergangene Bars — kausal. Aber: r.rolling(96, min_periods=48).std() mit den Default-Pandas-Settings — ist das wirklich kausal (nur backward) und nicht etwa centered? .rolling(N) ist by default right-aligned (label = right edge), nutzt also nur die letzten N Werte. ✅ Kausal.
VRP (Exp #19) — DVOL-Timing genau
DVOL daily close stamped 00:00 UTC. Wir ffillen auf 4h-Panel ohne explicit shift. Das bedeutet: am Bar 2024-01-11 00:00 ist DVOL = der heutige Daily-Close. Der ist aber erst um 00:00 UTC verfügbar. Wenn wir prediction für die NÄCHSTE 4h-Vol bauen (also Vol von 00:00-04:00), nutzen wir DVOL der gerade erst "abgeschlossen" wurde. Knapp aber okay — die Information ist physisch live.
Im Master-LGBM verwenden wir .shift(1) für 1h-Lag — konservativer. ✓
Stablecoin (Exp #17) — sc_daily Timestamp-Semantik
Wann genau veröffentlicht DefiLlama den totalCirculatingUSD für "2024-01-11"? Die API hat keinen explizit dokumentierten Release-Zeitpunkt. Konservativ: 1d shift in Master-LGBM ist ausreichend.
Walk-Forward Embargo prüfen
walk_forward_splits(embargo_minutes=max(1440, 4*60)) ist 1440 min (1 Tag), was bei einem fwd-4h Target ausreicht. Aber für fwd-1d Targets müsste embargo ≥ 1d sein. In Master-LGBM target ist fwd-4h, also 1440 reicht ≥ 4h.
In Exp #15 BOCPD: embargo_minutes=max(1440, 4*60) = 1440 min, target ist fwd-4h. ✓
In Exp #18 ETF-Flow: embargo_minutes=1440 mit target fwd-1d. Wir bräuchten ≥ 1d Embargo. Es ist genau 1d → grenzwertig aber OK.
Lessons Learned
-
Pandas-Resampling-Semantik prüfen.
label="right", closed="right"bei daily aggregation packt einen ganzen Tag in das Mitternacht-des-FOLGE-Tages-Label. Das ist intuitiv, aber wenn man danachshift(-1)macht, landet man in der Vergangenheit. Bei nicht-trivialen Datenquellen (z. B. Farside) immer den Release-Zeitpunkt explizit denken, nicht den parquet-Timestamp blind übernehmen. -
Same-day Confound erkennen. Jedes Mal wenn ein Feature und ein Label am selben Tag stattfinden (selbst wenn das Label im DataFrame als "forward" gelabelt ist), Confound prüfen. Standardtest: feature_t → ret_pre_t IC messen. Wenn das hoch ist, mit Residualisierung gegenchecken.
-
Causal Forward Filter ≠ Smoothing. Bei HMM-/BOCPD-Featuren ist die Wahl zwischen forward-only Filter und backward-smoothing der häufigste Lookahead-Spot. Wir nutzen strikt forward — gut.
-
Feature Importance hilft beim Sanity Check. Wenn ein vermeintliches Edge-Feature (ETF) im Master-LGBM nicht in Top-Importance landet, ist das ein Hinweis dass der Standalone-IC vielleicht Confound war.
Action Items
- ✅ Synthese-Doc updaten — ETF-Flow als ❌ statt ⭐
- ⏳ Master-LGBM-Validierung ohne ETF-Feature: erwarteter R²-Lift identisch oder leicht höher → bestätigt, dass ETF nicht wesentlich beiträgt
- ⏳ Bei zukünftigen Experimenten: vor
dropna()undshift()einen Trace-Plot erstellen (Feature-Wert + Label-Wert plotten über Time mit Annotations für Release-Zeitpunkte) - ⏳ Embargo in
walk_forward_splitsfür 1d-Targets auf 2d setzen (Sicherheitspuffer)