Door · ·

Honderd technische indicator-functies in JavaScript

Honderd technische indicator-functies in JavaScript

Mijn eerste open-source project is een library voor technische analyse genaamd ta.js — ongeveer honderd indicator-functies in JavaScript, eerste commit op 2020-08-24, v1.17.0 eerder deze week uitgebracht. Ik ben ermee begonnen omdat ik de indicator-functies sowieso voor mijn eigen werk nodig had, en ze allemaal zelf uitschrijven bleek de schoonste manier om ze te leren. De commits van de eerste drie dagen voegden Money Flow Index, Rate of Change en On-Balance Volume toe; de RSI-fix landde vier dagen later; Stochastics twee weken erin. De library werd gebouwd parallel aan mijn werk door het studieboek heen. Ik heb hem gepubliceerd omdat als iemand het echt zou gebruiken, ze issues konden openen op de functies die ik fout had, en die feedback-loop van buitenaf was goedkoper dan wat ik voor mezelf had kunnen bouwen.

De eerlijke lezing van het ding vijfeneenhalf jaar later: ik heb mijn eerste open-source project in JavaScript gebouwd omdat ik toen nog niet wist hoeveel uitvoeringssnelheid uitmaakt in kwantitatieve systemen — de taal bleek verkeerd voor de backtest en goed voor de helft van het werk waar ik nog aan het uitvogelen was wat ik aan het bouwen was. Die tradeoff heb ik aan het begin niet bewust gekozen. Het is iets dat ik later heb geleerd, toen ik backtests op niet-triviale schaal had geprobeerd te draaien en zag dat ze lang genoeg duurden dat de enige eerlijke reactie was om het productiewerk naar een andere stack te verplaatsen. De library bleef waar hij zijn plek nog verdiende, en dat was alles rondom de backtest — data inladen, slicen, een indicator tegen een venster van OHLC plotten in een browsertabblad, itereren op een formule totdat die overeenkwam met wat het studieboek zei dat die moest zijn.

De vorm

Een stuk of honderd standalone pure functies — moving averages, oscillators, bands, volatiliteits-indicatoren, statistiek, chart-transformaties, een PRNG — elk in zijn eigen bestand, elk kort genoeg om in één keer door te lezen. De kleinste moving-average implementatie is 9 regels JavaScript. RSI is 12. De hele library heeft één devDependency (esbuild, voor de bundles) en nul runtime dependencies.

Hij wordt op drie manieren gedistribueerd: het npm-package, een IIFE-bundle gehost op unpkg die je in een HTML-pagina kunt plakken met een <script src="https://unpkg.com/ta.js/ta.iife.min.js"> en aan kunt roepen als ta.sma(...) tegen een browser global, en een ESM-bundle van dezelfde host voor named imports. De IIFE-route is degene die ik nog steeds het meest bruikbaar vind. Elk HTML-bestand wordt zonder build-stap een charting-harness voor een indicator-idee, en dat was de modus waar ik feitelijk het grootste deel van de tijd in zat.

Het enige stuk in de library dat geen pure JavaScript is, is een Monte Carlo sim geschreven bovenop Node’s worker_threads. Die wordt tijdens runtime alleen geladen als Node ≥ 12 wordt gedetecteerd; browsers krijgen er in plaats daarvan een stub voor. De gate is een enkele if onderaan de entry-module, die de Node-versie afleest van process.versions.node en de worker wel of niet mount. Die voorwaarde is de enige omgevingscheck die de library heeft.

Waarom JavaScript

JavaScript was de taal waar ik op dat moment het snelst in was, en het was de taal die me de visualisatie-helft van de workflow gratis gaf. Een HTML-pagina met een <canvas> en een <script src="..."> is een charting-harness zonder build-stap, zonder virtuele omgeving, zonder toolchain. Ik kon een CSV in de browser laden, er een slice van doorgeven aan een indicator, en de output zichzelf zien tekenen op een canvas in hetzelfde tabblad waar ik de data open had. Voor het deel van het werk dat ik de meeste dagen feitelijk deed — “produceert deze formule een lijn die er hetzelfde uitziet als de studieboek-versie” — was die loop snel genoeg dat ik er nooit één keer aan heb gedacht om hem te vervangen. De CDN-distributie is op 2020-09-14 opgezet, drie weken na de start van het project. Visualisatie-vanaf-dag-één was de vroegste architecturale beslissing die de library nam. Er is ook een kleine React-companion op github.com/Bitvested/ta.web die de library wrapt voor chart-gebruik in een React-app; het is niet de canonieke manier om ta.js voor visualisatie te gebruiken — de IIFE-drop-in is dat — maar hij bestaat als je al in een React-codebase zit en dezelfde indicator-output naar een charting-component wilt sluizen.

De alternatieven waar de library vandaag de dag tegen gebenchmarkt zou worden zijn Python met NumPy of pandas, een C++ of Rust backtester, of een gehost product dat de backtest voor je draait. Elk daarvan zou een betere keuze zijn geweest voor het productiewerk. Geen daarvan zou me de in-browser-visualisatie-route hebben gegeven zonder meer infrastructuur dan het waard was voor een eerste open-source project, en dat was het deel van het werk dat ik feitelijk aan het doen was. De prijs van het kiezen voor JavaScript was dat het backtest-verhaal tegen een plafond zou gaan lopen dat ik niet had voorzien. Ik liep ertegenaan, haalde mijn schouders op, en heb het productiewerk uit de library gehaald. De library bleef waar hij zijn plek nog verdiende.

Array erin, array eruit

De API-vorm is over alle honderd-en-zoveel functies hetzelfde: pak een array, geef een array terug. ta.sma(prices, 14) eet een array van getallen en geeft een array van SMA’s terug. Indicatoren die OHLC nodig hebben nemen een array van twee-element of vier-element arrays. Het hele contract is “geef me de slice van historie die je wilt, krijg het resultaat voor die slice terug.”

Het alternatief dat ik niet heb gebouwd was een streaming-API — een indicator-instantie waar je één voor één prijzen in pusht en die op elke push de volgende waarde emit, met zijn interne state gecached. Dat zou het juiste design zijn geweest voor live-tick-verwerking, waar het dure ding het state-keep-alive over ticks heen is in plaats van de wiskunde zelf. Ik heb het overgeslagen omdat het niet aansloot bij hoe ik de library feitelijk gebruikte. Ik laadde een CSV, sliced 'm, gaf de slice aan een indicator door en plotte het resultaat. Een stateful API zou extra oppervlak zijn geweest voor een use-case die ik niet had.

De prijs van de array-in-vorm is dat de library structureel niet geschikt is voor live ticks. Elke call loopt het venster opnieuw door; er is geen incrementele berekening. Voor wie het in een live-loop probeert te gebruiken, is alles rond online updates en feed-integratie aan de aanroeper overgelaten. Dat is de prijs van een onderzoeks-API voor een onderzoeks-use-case.

Plat voor vijfeneenhalf jaar

Voor bijna de hele levensduur van dit project was de broncode-layout één platte entry-module met elke indicator en elke hulpfunctie geregistreerd tegen één enkel object. Dat was een bewuste keuze. Plat is triviaal greppable. Plat is triviaal overrideable — ta.ema = myEma en elke andere indicator die intern ta.ema aanroept pakt de override op bij zijn volgende call. Plat is triviaal copy-pasteable in een <script>-tag wanneer je iets nieuws wilt prototypen zonder het eerst te publiceren. De platte layout paste beter bij hoe de library werd gebruikt dan een map-structuur dat zou doen.

Dat heeft standgehouden tot deze week. De laatste commit vóór die van deze week was 2024-07-22, dus het project was ongeveer 22 maanden inactief geweest voordat de refactor landde. De refactor zelf was één push van twee uur: de eerste PR splitste elk bestand onder een per-categorie map-structuur, de tweede verving de handgebouwde bundle door een esbuild-pipeline. De nieuwe layout is leesbaar op een manier waarop de platte versie dat niet was, maar het override-pattern was zo cruciaal dat het loslaten geen optie was. De fix is een shared indirection: een module van vier regels die een leeg object exporteert, dat de entry-module tijdens het laden vult en waar elk per-function bestand uit leest wanneer het een andere indicator moet aanroepen. De functie-bestanden zien er nu zo uit:

const ta = require('../_registry.js');
function sma(data, length=14) {
  for(var i = length, sma = []; i <= data.length; i++) {
    var avg = ta.sum(data.slice(i-length,i));
    sma.push(avg / length);
  }
  return sma;
}
module.exports = sma;

De registry-module is:

module.exports = {};

Consumer-overrides propageren nog steeds op dezelfde manier als altijd — de entry-module vult het registry-object tijdens het laden, dus een monkey-patch zoals ta.sma = myOverride wordt door elke afhankelijke indicator op zijn volgende call gelezen. De refactor was niet de moeite waard geweest om te shippen zonder dat stuk. v1.17.0 ging dezelfde middag de deur uit als beide PR’s, zonder soak-window tussen de refactor en de release. Als er iets in de nieuwe layout fout is op een manier die ik nog niet heb gezien, gaat dat boven water komen via de mensen die het package gebruiken, niet via mij.

Open source als externe kritische blik

Ik heb deze library niet gepubliceerd omdat ik dacht dat de wereld nog een TA-library nodig had. Ik heb hem gepubliceerd omdat ik wilde dat de functies die ik had geschreven werden bekeken door mensen die de wiskunde beter kenden dan ik. Als ik de definitie van MFI fout zou hebben of de standaardformule voor de stochastische K fout, hoorde ik dat liever via een issue op GitHub dan via een sim die zes maanden later stilletjes was afgedreven van de markt.

Die loop heeft zich daadwerkelijk gesloten. Issues zijn binnengekomen — geen vloedgolf, maar genoeg om implementaties te corrigeren die ik op eigen houtje niet zou hebben gecorrigeerd, en genoeg om me op dingen te wijzen die ik niet wist toen ik de eerste versie schreef. De open-source-houding heeft gedaan wat ik wilde dat het deed: het correctheidsverhaal van de library veranderd van “ik denk dat deze kloppen” naar “andere mensen hebben hier naar gekeken en hebben me verteld wanneer ze niet kloppen.” De kostenkant hiervan is degene die de meeste open-source maintainers wel kennen — PR’s om te triagen, verwachtingen van gebruikers waar ik niet voor getekend had — maar voor een indicator-library van honderd functies die nooit heeft geprobeerd voorbij zijn oorspronkelijke scope te groeien, is die kost beheersbaar gebleven.

De tests zijn een regression guard, geen correctheidsverhaal

De tests zijn een platte assert.deepEqual-loop over een fixtures-bestand. Geen framework, geen assertion-library, geen setup. Drieëntwintig regels test-runner, één expected-output fixture per indicator, en een try/catch per fixture die bij een mismatch naar console.error logt en doorgaat. De belangrijkste regel ziet er zo uit:

try { assert.deepEqual(ta[x].apply(null, parameters[x].in), parameters[x].out); } catch(e) { console.error('Test failed @ ' + x + '\n'); }

Wat dit checkt is “de output van deze indicator op deze fixture is niet veranderd sinds de laatste keer dat ik de fixture heb ingecheckt.” Wat het niet checkt is “de output klopt.” De fixture is berekend op basis van de implementatie op het moment dat ik hem schreef. Als de implementatie toen fout was, is de fixture nu fout, en de test asserteert dat de foute output stabiel fout is gebleven. Dat is een echte kanttekening. Adversarial test-design had een aantal van de bugs gevangen die in plaats daarvan als issues in mijn inbox landden.

De tweede eerlijke kanttekening over de test-setup is dat de runner exit 0 doet zelfs wanneer een fixture faalt. Fouten gaan naar console.error en het proces geeft nog steeds succes terug. CI kan de build niet falen op een regressie zonder stderr te greppen voor de failure-banner, en dat heb ik nooit ingericht. Dat is een bug in de runner, niet in de tests, maar het is een bug die ik niet heb gefixt omdat het me voor een library van deze vorm tot nu toe niets heeft gekost dat ik heb gemerkt.

De rest van de eerlijke lijst

Dingen waarvan ik weet dat ze niet perfect zijn en die ik niet heb gefixt, in hetzelfde register:

  • Er is een hand-onderhouden minified bundle die ouder is dan de esbuild-pipeline en die nog steeds met het package meegaat. Het is een derde source of truth naast de twee door esbuild gegenereerde bundles, zonder geautomatiseerde regenerator, en hij kan stilletjes uit de pas lopen met de broncode als ik vergeet hem bij een release opnieuw te genereren. Het werk van deze week heeft de andere twee bundles gemoderniseerd; deze ligt nog bij mij.
  • Zowel de README als het EXAMPLES-bestand dragen de anchor-lijst van elke indicator in de library. Eén indicator toevoegen is drie gecoördineerde wijzigingen — het indicator-bestand, de README-entry, de EXAMPLES-entry. Dat is genoeg wrijving om bijdragen van mensen die er anders één hadden ingestuurd stilletjes te ontmoedigen.
  • De Monte Carlo sim-worker roept terug in de main-module via naam in plaats van via referentie — ta.sum, ta.sma, ta.std, ta.normsinv — wat betekent dat een rename van een van die functies de simulatie stilletjes breekt. Er is geen type-contract dat dat opvangt. De tests draaien sim ook niet end-to-end, dus een gebroken call zou pas boven water komen wanneer een gebruiker een simulatie draait en een fout antwoord krijgt.
  • JavaScript zelf is te traag voor een production-grade backtest op enige serieuze schaal. Ik heb dit hierboven al opgeschreven, maar het hoort ook thuis in de eerlijke lijst, omdat iedereen die de library op een niet-triviaal venster van historie gebruikt er tegenaan zal lopen, en het is niet iets dat de library kan fixen zonder op te houden een JavaScript-library te zijn.

Ik heb geen haast gehad om hier iets van op te ruimen. De release van deze week heeft de distributie-pipeline gemoderniseerd omdat dat het deel van het project is dat de mensen die het nog gebruiken daadwerkelijk aanraken. De ruwe randjes hierboven zijn randjes waar ik sinds 2020 mee leef en ze hebben me tot nu toe geen issue gekost die ik niet kon absorberen.

Waar het nu staat

Ik gebruik deze library minder dan ik jaren geleden deed. Dat is het waard om vooraf te zeggen, omdat het de rest van het antwoord vormgeeft. Het productiewerk rond indicatoren leeft nu op een andere stack. ta.js is nog steeds het instrument dat ik pak wanneer ik een formule wil prototypen in een browsertabblad of iemand een <script src="..."> wil geven om ze te laten zien wat een indicator eigenlijk is. Dat is een smallere rol dan waarmee het is begonnen, maar het is nog steeds een echte rol, en de mensen die het nog gebruiken, gebruiken het voor iets dat dicht bij dezelfde vorm van werk ligt.

De release van deze week ging er niet over om het project weer open te zetten voor actieve feature-development. Het ging erover om de internals up-to-date te brengen voor de mensen die het nog gebruiken — een schonere broncode-layout, een moderne bundle-pipeline, een override-mechanisme dat de refactor overleeft. Ik heb agentic coding tools die me helpen met het nakijken en aanscherpen van de stukken die ik op eigen kracht niet in een redelijke twee uur had kunnen aanscherpen. Waar ik me niet aan heb gecommitteerd is een lijst nieuwe functies om te schrijven. Ik weet nog niet of er veel over zijn die het waard zijn om aan een library op deze scope toe te voegen.

Als er een nieuwe ronde functie-werk op gaat komen, is het ding dat ik het meest wil behouden de oorspronkelijke houding: kleine, copy-pastable indicator-functies, één per bestand, kort genoeg om in één keer door te lezen. Toen ik in 2020 de eerste versie schreef kon ik geen library vinden die paste bij de use-case die ik had — het meeste wat er was voelde bloated, met meer oppervlak dan functie — en de minimalistische vorm van wat ik uiteindelijk heb gepubliceerd was een directe reactie daarop. Wat er ook komt, het moet nog steeds slagen voor de “kan ik dit in een <script>-tag plakken en is het op zichzelf logisch”-test. Zo niet, dan voeg ik het liever niet toe.

Lees ook