SAW — a backtesting, walk-forward and optimization engine built from scratch
Quantitative Research
TypeScript / Node.js
Genetic Optimization
Research Tooling
I did not want to trust a black-box backtester with my own capital, so I wrote one.
SAW is a self-contained quant framework in TypeScript: a candle-driven backtesting core, a walk-forward validation harness, an evolutionary parameter optimizer, an exchange connector layer with its own rate-limit infrastructure, and a research dashboard on top of the results.
~12 900 lines of authored TypeScript · 119 commits · 13 GB of collected market history · 2 163 instrument datasets · 6 399 stored optimization results across 7 research campaigns.
Why build the engine yourself
Because the interesting decisions live inside the engine, and they are exactly the ones off-the-shelf tools hide:
- how a limit order inside a grid is considered filled against an OHLC bar,
- whether maker and taker fees are charged on the right side of the trade,
- what happens to the equity curve when a position stays open across the end of the window,
- how minimum notional size silently truncates the number of grid levels you thought you had.
Owning that code means every metric downstream is something I can defend line by line.
Walk-forward as a first-class citizen
Optimization without out-of-sample validation is curve fitting with extra steps. The harness treats it as the default path, not an afterthought.
Rolling window generation. Windows are defined by the out-of-sample length and an in_out_ratio, and are walked from the freshest data backwards. Each step slices matching in-sample and out-of-sample chunks of the loaded market data.
Per-window evolution. On every window the genetic optimizer runs against the in-sample slice only; the best genome is then replayed on the untouched out-of-sample slice. Metrics from both samples are persisted — the gap between them is the actual result.
Robustness scoring. Each segment gets
robustness = (OOS net profit / IS net profit) × in_out_ratio
which normalises the fact that the out-of-sample slice is shorter than the slice the parameters were fitted on. A configuration that only works in-sample scores near zero by construction.
Early rejection. If the share of losing segments crosses the configured threshold, the instrument is abandoned mid-sweep instead of burning CPU on a hypothesis that already failed. Across a 2 000-instrument sweep this is the difference between a run that finishes and one that doesn’t.
Fitness design — defending against small-sample artifacts
A naive multi-metric fitness is trivially gamed: the optimizer discovers that two lucky trades produce a spectacular Sharpe ratio and stops looking for anything else.
The fitness function normalises every metric min–max within the current population, then combines net profit, Sharpe ratio and trade count with weights — and critically, the weight applied to Sharpe is itself the normalised trade count. A configuration with a beautiful Sharpe on a handful of trades has that Sharpe discounted almost to nothing, while a configuration that trades often has it counted in full.
The optimizer itself is a generational genetic algorithm with operators I wrote against the shape of a config rather than a flat vector: deepClone / mutate / deepCrossover recurse through an arbitrarily nested configuration object and handle Decimal, integer and float fields correctly, with selected parameters explicitly excluded from mutation. The initial population is generated as random valid configs directly from the strategy’s Zod schema — the schema is the search space.
Reproducible research pipeline
The whole study is a chain of CLI commands, each with its own versioned config:
| Command | What the stage does |
|---|---|
download-coin-history / download-all-coins | Pulls OHLCV history from the exchange in chunks, working around the API’s history-depth limit |
csv-joiner → csv-filter | Merges and dedupes datasets, filters the instrument universe down to what is worth testing |
find-atr-drift-instrument | The core sweep: rolling walk-forward windows plus genetic optimization, run across every surviving instrument |
filter-wf-results | Multi-stage threshold filtering of the results, each stage emitting its own Excel report |
final-pretrain | Refit of the winners to produce the parameter set that actually ships |
wf-results-dashboard | Express + React + ECharts UI for inspecting equity curves and per-segment metrics |
Two properties I insisted on:
- Runs are resumable. The run config is copied into the results folder and compared on restart with
isDeepStrictEqual; already-processed symbols are skipped, and each instrument writes its own result file. A sweep that dies at hour nine resumes at hour nine. - Experiments are versioned next to their results. The configs of every campaign live in git, so the evolution of the methodology is visible in the diff: population size 200 → 500,
in_out_ratio2 → 4 → 6.66, and the minimum positive-segment ratio tightened from 0.1 to 0.8 as I learned how much slack was too much.
Engineering notes
Schema-first with Zod. One schema serves as command config contract, strategy parameter space, exchange response validator and random-config generator for the optimizer. Backtest and live modes are separated at the type level: a superRefine requires exactly one of live_settings / backtest_settings, so a misconfigured run cannot start rather than failing halfway.
Correct money arithmetic. Balances and PnL go through decimal.js — a dedicated DecimalBalance type rather than floats accumulating error over tens of thousands of fills.
Streaming data layer. OHLCV CSVs are parsed as streams with byte-level progress and hard startIndex / stopIndex cut-off that destroys the stream early, which is what makes multi-gigabyte sweeps feasible in a single Node process.
Exchange rate limits as infrastructure. A proxy pool tracks a rate-unit budget per node with timer resets and routes each request to the node with the most remaining quota; per-method weights are declared with a @RateLimit decorator. This was the direct product of my own research into MEXC’s real limit structure.
Order lifecycle for live trading. A virtual-order layer with its own state machine (IDLE → PENDING_OPEN → OPEN → FILLED / CANCELED / ERROR), a virtual↔exchange ID mapping, separate historical and active fill accounting, and state persisted under file locks so a restart doesn’t lose track of what’s on the book.