27 augustus 2026 · data-engineering

Je symbooluniversum is een tijdmachine: survivorship bias in crypto-perpbacktests

Je symbooluniversum is een tijdmachine: survivorship bias in crypto-perpbacktests

Je stuurde me zondagavond het notebook en sindsdien staat het open op mijn tweede monitor. Cross-sectioneel momentum, de 150 Binance USDⓈ-M-perps met het hoogste dollarvolume over 30 dagen, wekelijks herbalanceren, long in het bovenste deciel en short in het onderste, van 2021-01 tot en met 2026-06. Sharpe 2.31, maximale drawdown 14.2%, en een kostenmodel dat er eerlijk uitzag: taker bij het openen, taker bij het sluiten, funding opgebouwd per interval, en een slippagecomponent die schaalt met je ordergrootte ten opzichte van het orderboek. Je hebt de lastige onderdelen goed aangepakt. Daarna vroeg je waarom zes weken paper trading niets hadden opgeleverd en of het voordeel was verdwenen.

Het is niet verdwenen. Het zat nooit in de backtest. Kijk naar cel 4:

info = requests.get(BASE + "/fapi/v1/exchangeInfo").json()
symbols = [s["symbol"] for s in info["symbols"]
           if s["status"] == "TRADING" and s["quoteAsset"] == "USDT"]

Je hebt dat endpoint in juni 2026 aangeroepen en het antwoord gebruikt om te bepalen wat er in maart 2021 verhandelbaar was. Elk symbool op die lijst had per definitie juni 2026 overleefd. Dat is de hele fout, en die weegt zwaarder dan de rest van je kostenmodel bij elkaar.

2.31 → 0.74Sharpe, overleverslijst versus point-in-time
~1 op 5van je universum uit 2021 bestaat niet meer
380 KBeen dagelijkse exchangeInfo-snapshot, gecomprimeerd met gzip

Wat het endpoint je niet vertelt

exchangeInfo bevat geen geschiedenis. Geen parameter asOf, geen archief, geen changelog. Het is een momentopname van nu, en Binance heeft nooit iets anders beloofd. Wanneer een contract wordt geschrapt, verdwijnt de vermelding uit het antwoord en houdt de ticker op te bestaan voor zover de API weet. Binance heeft sinds 2019 meer dan 600 USDⓈ-M-perpetuals genoteerd en er ruim honderd geschrapt. BTS, COCOS, TOMO, RAY, FTT, SC: een lange staart van altseason-contracten uit 2021 die één schitterend kwartaal kenden en daarna steeds minder werden verhandeld, tot het platform ze verwijderde.

Bedenk eens welke namen een momentumfilter van 30 dagen graag oppikt. BTC niet. Het filter houdt van iets dat net verdrievoudigde door een listingpomp en een Twitter-hype. Die groep overlapt sterk met de groep die uiteindelijk wordt geschrapt, en jouw universumfilter haalde die overlap eruit.

Ik heb je run opnieuw uitgevoerd met ons snapshotarchief, een point-in-time-universum en hetzelfde signaal en dezelfde kosten. De Sharpe kwam uit op 0.74, met een drawdown van 31%. Ongeveer twee vijfde van het verschil komt door geschrapte namen die je nooit had mogen aanhouden. Nog een kwart komt door het omgekeerde probleem, dat subtieler is en je vermoedelijk minder zal bevallen.

De andere kant van de tijdmachine: symbolen die nog niet bestonden

Je feature is een rendement over 90 dagen. Je rollende venster heeft min_periods=20, omdat je die instelling één keer hebt gekozen om te voorkomen dat de opwarmperiode het eerste kwartaal van de steekproef wegvaagde, en er daarna niet meer naar hebt omgekeken. Een contract dat 21 dagen geleden is genoteerd, krijgt dus een momentumscore op basis van drie weken koersbeweging na de notering. Als de notering goed verliep, belandt het ongeveer altijd in het bovenste deciel en komt het in je portefeuille.

Dat deel is verdedigbaar. Dit is het probleem: je dataleverancier heeft voor sommige van die symbolen spot- of indexdata aangevuld van vóór het bestaan van de perp. Daardoor hebben een handvol contracten kline-historie van vóór hun eigen onboardDate. Je kunt dit in ongeveer een minuut controleren. Koppel je prijstabel aan de onboarddatums en tel de rijen van vóór de notering. In je data zijn er 41 symbolen met candles van vóór de notering. Eén daarvan levert in de zomer van 2023 een winst van 6% in één week op de vermogenscurve op, dankzij een positie in een contract dat pas negen dagen later zou bestaan.

Een tickerstring is geen identificatie. Het is een label dat de beurs verhuurt, soms twee keer.

Dan zijn er nog de naamswijzigingen. MATICUSDT werd POLUSDT. FTMUSDT werd SUSDT na een conversie van 1:1. De LUNA-situatie in mei 2022 leverde LUNCUSDT op en later een gloednieuwe LUNAUSDT, met dezelfde tickerstam als iets dat vrijwel alles had verloren. Als je loader de symbolstring als sleutel gebruikt en alle gevonden bestanden samenvoegt, zit er minstens één reeks tussen met een discontinuïteit die geen koersbeweging is. Je momentumfeature leest die discontinuïteit dan als het sterkste signaal in de cross-section.

Al het andere dat onder je neus verandert

Zodra je accepteert dat de symboollijst door de tijd heen verandert, geldt hetzelfde voor elk ander veld in dat antwoord. Je gebruikt voor al die velden de waarden van vandaag.

VeldHoe het verandertWat er misgaat
statusTRADING → SETTLING → verdwenenSurvivorship bias; fictieve exits tegen een slotkoers die nooit is verhandeld
onboardDateElke week nieuwe noteringen; ontbreekt bij verdwenen symbolenHandelen in contracten voordat ze bestonden
tickSize / stepSizeWordt aangepast naarmate prijsniveaus veranderenAfronding van orders en limietprijzen die zouden zijn afgewezen
minNotionalWordt na verloop van tijd verhoogd bij dunne orderboekenKleine posities die je live router zou weigeren
fundingIntervalHoursJarenlang 8h, daarna voor veel symbolen 4h of 1hCarry 2–3× verkeerd berekend, juist voor de alts die je filter aanhoudt
leverage bracketsTiers en onderhoudsmarge herzienLiquidatiemodellering en margincapaciteit

De fundingfout doet in jouw geval het meeste pijn. Je accrual-loop gaat uit van drie betalingen per dag gedurende de hele geschiedenis. Een flink deel van de altportefeuille is overgestapt op funding per vier uur, en een groot deel van je gesimuleerde PnL komt uit shortposities in namen met hoge funding. Je zit er niet een afrondingsfoutje naast. Je zit er een factor naast.

Ook het schrappen is een gebeurtenis, en die modelleer je niet

Bij je point-in-time-herberekening heb ik de strategie een ruime exit gegeven. Echte schrappingen verlopen volgens een vast draaiboek: een aankondiging, doorgaans zeven tot veertien dagen van tevoren, daarna een periode waarin je alleen posities mag verkleinen, en vervolgens verplichte afwikkeling tegen een markprijs. De aankondiging is openbare informatie en je kunt erop handelen, dus een eerlijke simulatie sluit de positie tegen de slotkoers van de aankondigingsdag. Maar bij een typische schrapping van een alt ligt die slotkoers al 10-20% onder die van de week ervoor, en is het orderboek dun. Je slippagemodel moet dus rekening houden met een ander marktregime. Als je fill-engine je zonder impact de afwikkelingsprijs geeft, laat je activa die verdwijnen ongemerkt tegen reële waarde verhandelen.

Als je exchangeInfo nooit hebt gearchiveerd, ben je nog niet verloren. De openbare dump op data.binance.vision/data/futures/um/monthly/klines/ bewaart nog lang mappen van geschrapte symbolen nadat de API ze is vergeten. Inventariseer de mappen: het eerste en laatste maandbestand per symbool geven je een bruikbare periode voor de notering en schrapping, zonder dat je een dataleverancier nodig hebt. Het is een reconstructie, geen registratie, en je haalt er geen tick sizes of fundingintervallen uit. Maar je ziet er wel aan welke symbolen wanneer bestonden, en dat is 80% van wat je maandag nodig hebt.

Wat ik zou bouwen voordat je het signaal weer aanraakt

  1. Een cron-taak die dagelijks exchangeInfo ophaalt bij elke beurs die je onderzoekt en het opslaat in object storage, gesorteerd op datum. Gecomprimeerd met gzip is het minder dan een halve megabyte. Tien jaar aan data kost je op S3 vrijwel niets en geeft je een onderzoeksmogelijkheid die je later niet meer kunt kopen.
  2. Een asset master op basis van die snapshots: één rij per (venue, symbol, valid_from, valid_to), met alle velden. Vergelijk opeenvolgende snapshots om die op te bouwen en maak bij elke veldwijziging een nieuwe rij.
  3. Een universumfunctie met een verplichte tijdstempel als argument. universe(ts), nooit universe(). Zorg dat je nooit standaard een waarde hebt, zodat niemand — ook jijzelf niet om 1am — per ongeluk de overleverslijst kan gebruiken.
  4. Een controle in de dataloader die pre-listingdata afwijst: er mag geen candle bestaan van vóór onboard_ts minus één dag. Breek de run af; geef geen waarschuwing.
  5. Een stabiel intern instrument-ID dat naamswijzigingen overleeft, waarbij de ticker alleen nog een attribuut is. Koppel POL en MATIC aan hetzelfde ID en markeer redenominaties, zodat de continuïteitslogica weigert ze samen te voegen.

Doe dit en voer de run opnieuw uit. Ik verwacht dat je in de buurt komt van mijn 0.74. Dan wordt de interessante vraag of er in een 0.74 waarin de verdwenen namen zijn meegenomen genoeg zit om paper trading te rechtvaardigen. Misschien wel. Cross-sectioneel momentum in perps is niet waardeloos, en een deel van wat overblijft nadat je het universum hebt gecorrigeerd, is echte carry aan de shortkant. Je zult ook zien dat je paperresultaten en je backtest het eens beginnen te worden, omdat paper trading altijd al met een point-in-time-universum draaide. Het kon niet anders.

P.S. Dit is geen eigenaardigheid van crypto; hier valt het alleen meer op. Aandelenonderzoekers worstelen al sinds CRSP data levert met rendementen rond schrappingen en hergebruikte tickers. Voorspellingsmarkten gaan nog een stap verder: elk contract loopt per definitie af, dus het universum bestaat uitsluitend uit noteringen en verdwijningen. Als je dit filter ooit naar Kalshi overzet, bouw dan eerst de asset master. Daar bestaat geen andere soort geschiedenis.

survivorship biaspoint-in-time-datacrypto-futuresbacktestingdata-engineering
← Alle artikelen