A quarterly crypto index fund built on backtest-kit: a fixed deposit is spread in equal parts over the twenty most traded Binance USDT pairs of the month before the quarter, the share of the deposit that is actually in the market is dictated by a trend gate on Bitcoin, and whatever the gate keeps out of crypto is parked in tokenized gold. Every position is opened on the first tick of the quarter and closed by time on its last day. One folder per quarter, no shared code between quarters, daily candles from Yahoo Finance sampled into the minute feed the engine expects.
Most "crypto index" ideas die on two details that are easy to hand-wave in a spreadsheet and impossible to hand-wave in an engine: which coins were actually in the index in a given quarter (not the ones that survived until today), and what happens to the money when the index is not worth holding. This repo answers both mechanically. The basket of each quarter is frozen into that quarter's folder from data that was known before the quarter started, and the deposit never sits idle: the trend gate splits it between the alt basket and gold on the day the quarter opens, with the whole decision visible in every signal's payload.
- The index is rebuilt every quarter from the volume of the month before it. No survivorship: delisted and forgotten coins are in the baskets of the months when they were liquid, and this year's memecoins are not in 2021. The list a quarter trades on is literally a constant in that quarter's
main.strategy.ts. - The gate is a vote of six windows, not a single moving average. For each of 20, 25, 30, 35, 40 and 45 days the strategy asks whether Bitcoin closed higher than that many days ago; the share of "yes" answers (0, ⅙ … 1) is the share of the deposit that goes into the basket. A single lookback is a knife-edge parameter; an ensemble across a plateau of lookbacks is not.
- Gold is the counter-position, not cash.
1 − gateof the deposit buys PAXG for the same quarter. When the gate is 0 the fund is entirely in gold; when it is 1 the gold leg returns no signal at all. - Everything is a quarterly position with a time exit. The take-profit at +500% exists only to satisfy the engine's validator; the stop at −50% is a real drawdown cap over three months and does get hit in crashes, after which the symbol re-enters on the next tick with the gate of that day. The normal way out is
time_expiredon the last day of the frame.
The repo is a template for this kind of research, and it says so in its structure: 24 quarter folders generated by copy-paste from one reference quarter, three scripts that build the universe and vet the data source, and a HOWTO that lists every known approximation.
📚 API Reference | 🌟 Quick Start | 📰 Article
1. Start the persistence the engine expects (config/loader.config.ts waits for both before loading any strategy):
npm install
docker compose -f docker/mongo/docker-compose.yaml up -d
docker compose -f docker/redis/docker-compose.yaml up -d2. Run one quarter (the frame and the exchange adapter live next to the strategy, in content/q3_2026/modules/backtest.module.ts):
npm start -- --backtest --ui --entry ./content/q3_2026/main.strategy.ts3. Run every quarter in chronological order, one engine process per quarter:
bash scripts/linux/run_strategy.sh # scripts/win/run_strategy.bat on WindowsEach quarter writes content/<quarter>/dump/report/backtest.jsonl; opened rows carry the gate, the weight and the basket size of that signal in payload, closed rows carry the realized PnL. Daily candles are fetched from Yahoo on demand, one request per ticker, nothing is written to Mongo for them. node scripts/common/summarize_reports.mjs folds all quarters into one table and flags anything that did not close by time.
The whole strategy is one file per quarter, content/q4_2020/main.strategy.ts being the reference the others are stamped from. Reading it top to bottom:
Twenty symbols plus PAXGUSDT. The twenty are the top Binance USDT spot pairs by quote volume of the calendar month before the quarter, with three filters: listed at least 90 days before the quarter starts, not a stablecoin, fiat, leveraged token or wrapped/staked derivative of BTC or ETH, and tradable on the candle source for the whole quarter (see the data section). The ranking uses only information available on the first day of the quarter; the block between // BASKET:BEGIN and // BASKET:END is the only thing that differs between the strategies of two quarters.
gate = share of N in {20, 25, 30, 35, 40, 45} with close(yesterday) > close(yesterday − N days)
Computed on Bitcoin's closed daily bars through getCandles, which the engine trims at the current backtest time, so there is no look-ahead. Wrapped in Cache.fn with a one-day interval: the value is calculated once per day for all symbols and invalidated when the engine moves to the next day. In a quarterly cycle it matters on the opening day and on any re-entry after a stop.
- every basket symbol:
cost = (deposit / basket size) × gate PAXGUSDT:cost = deposit × (1 − gate)- any leg whose cost falls below one dollar returns
nullinstead of a signal, because the engine silently replaces a zero cost with its default entry cost.
The deposit is a constant of the strategy. backtest-kit has no shared portfolio balance: each symbol is an independent backtest, and the "fund" exists only as this arithmetic plus the sum of pnlCost over the quarter's report.
A position is opened long with Position.bracket (stop −50%, take-profit +500%) and minuteEstimatedTime of 92 days minus one minute, longer than any quarter: the engine closes whatever is still open at the frame's endDate with time_expired, so every leg exits at 23:59 of the quarter's last day. There is no re-entry inside the quarter except after a stop, where the symbol re-enters on the next tick with that day's gate and holds to the same end of frame. One round trip per leg per quarter is the whole cost budget of the fund.
content/q<n>_<yyyy>/ is one calendar quarter and is self-contained: the strategy, the candle sources and the exchange adapter with the quarter's frame. There is no shared module between quarters, which is a rule of this repo rather than an omission. A backtest should be reproducible from the folder alone, quarters must never influence each other through a refactor, and the reference quarter is the only place where the strategy is edited by hand:
| Path | Role |
|---|---|
main.strategy.ts |
the fund: constants, gate, allocation, launcher with the quarter's basket; setConfig lives only here |
classes/YahooFinance.ts |
daily candles from Yahoo Finance chart API, no API key, no ccxt; compatible with getCandlesFilled / getCandlesSampled as a fetcher |
classes/BinanceFutures.ts, classes/BinanceFunding.ts, classes/UzSE.ts |
other candle and data sources in the same fetcher shape, not used by the fund itself: Binance USDⓈ-M klines and funding history, Tashkent stock exchange daily bars cached as Excel exports |
modules/backtest.module.ts |
symbol → Yahoo ticker mapping (BASEUSDT → BASE-USD plus the quarter's explicit overrides), the exchange schema, the quarter's frame |
The generator in scripts/common/generate_months.mjs copies the reference folder into every quarter and substitutes exactly three things: the basket block in the strategy, the ticker overrides block and the three-month frame in the module. Quarters that have not ended yet are skipped. It also rewrites the two runner scripts so that they always list every quarter on disk.
The engine simulates positions on minute candles and asks the exchange adapter for them. Yahoo only has daily bars. The adapter bridges the two with backtest-kit's own tools:
- every request goes through
getCandlesSampled; a1drequest is handed togetCandlesFilled, which puts the bars on a continuous calendar grid and repairs weekends, holidays and anomalous bars (zeros, hundredfold wicks) with a flat bar from the neighbouring closes, and anything shorter repeats the repaired daily bar across its minutes or hours; - the strategy sets
CC_BACKTEST_STEP_MINUTESto a full day, so the engine evaluates each position once per daily bar instead of 1440 times on identical candles; PersistCandleAdapter.useDummy()keeps the synthesized candles out of Mongo.
The accepted trade-off is printed in the engine's own documentation of the sampler: every minute of a day carries that day's high, low and close. For a time exit on the last day of the quarter this does not matter; for the −50% stop it means the stop fires on the day's low rather than at an intraday moment.
- Copy-paste over abstraction. Months are stamped, not imported. A refactor must not be able to change the result of a month that already ran.
setConfigonly in the strategy file. The exchange module registers schemas and adapters; it does not change engine behaviour.- Constants describe the process, not the outcome. The gate windows are a plateau found before this repo existed, the holding period is a calendar quarter, the listing age is a liquidity requirement. None of them is tuned to a PnL curve, and the README does not quote one.
- Every approximation is written down. docs/HOWTO.md is the operating manual: prerequisites, the universe/resolve/verify loop, how to change the strategy (edit the reference month, regenerate), and the list of known gaps: the daily-bar sampling, the per-symbol accounting, the Yahoo aggregate prices, re-entries after a stop.
| Aspect | This repo |
|---|---|
| Subject | A quarterly long-only crypto index fund with a Bitcoin trend gate and a gold counter-position, replayed quarter by quarter |
| Universe | Top-20 Binance USDT pairs by volume of the month before the quarter, 90-day listing age, no stables/leveraged/wrapped; frozen per quarter, no survivorship |
| Gate | Share of six lookbacks (20–45 days) on which BTC closed higher; scales basket exposure from 0 to 1 |
| Counter-position | PAXGUSDT receives 1 − gate of the deposit for the same quarter |
| Cycle | Equal-weight positions opened on the first tick of the quarter, time exit at the end of the frame, re-entry only after a −50% stop |
| Data | Yahoo Finance daily bars, sampled to minutes by getCandlesSampled, one engine step per day, tickers resolved and price-verified against Binance before generation |
| Layout | 24 self-contained quarter folders generated from one reference quarter; generator, universe builder, resolver and verifier in scripts/common/ |
| Honesty budget | Per-symbol accounting with a fixed deposit, synthetic intraday path, stops evaluated on daily lows; all documented in docs/HOWTO.md |
