Kestrel Backtest Jobs, Parameter Sweeps and Honest Walk-Forward

How do you run hundreds of backtests without cheating, without a network, and without fooling yourself?

Intro

Hundreds of Backtests, One Question

Run a thousand parameter combinations, pick the best one, and you have almost certainly found noise. The hard part of research is not running backtests. It is running them in a way that cannot fool you. Today we follow a backtest through Kestrel's research service, and then through a sweep tool designed to stay honest.

  • Best of many is often noise
  • Honest research needs structure

Where We Are in the Series

In episode fourteen we built the metrics: Sharpe, Sortino and drawdown, computed with Welford updates. We will not repeat those formulas here. This episode is about the machinery around the engine: the portal that queues jobs, and the sweep tool that scores them without lying to you.

  • Episode 14 gave us metrics
  • Now: jobs, sweeps, walk-forward

The Plan

First the portal: its config, its submit endpoint, its job manager and the way a result is saved. Then the offline sweep tool, with its proof that nothing leaves the machine. Finally walk-forward folds and the score function, with a worked example using concrete numbers. Everything here is educational, and every number is illustrative.

  • Portal and job pipeline
  • Offline sweep tool
  • Walk-forward and scoring

The Portal Service

kestrel_portal and portal.json

kestrel_portal loads config slash portal dot json. It listens on 127.0.0.1 port 8093, loopback only. Limits are explicit: two backtest workers, one download worker, at most 32 queued jobs, 200 retained results, a 366 day maximum backtest span, and 3600 seconds per job.

  • Listener 127.0.0.1:8093
  • 2 backtest workers, 1 download
  • maxQueued 32, maxRetained 200

Result Size Limits and Defaults

Result size is bounded too. Curves and price series are decimated to 2000 points, and at most 20000 fills are stored. The defaults for a job are capital of 1,000,000 rupees, the india cost model, slippage of 2, 1 and 5 basis points, and pricing with a rate of 6.5 percent and volatility of 20 percent. A request only needs to state what differs.

  • Curves capped at 2000 points
  • maxStoredFills 20000
  • Defaults: capital 1,000,000

JSON Only, Behind a Guard

The portal speaks JSON only. A request for the root path gets a plain 404, because there is no web page here; the browser gateway from the series overview lives elsewhere. Every handler sits behind a RequestGuard, which we cover in episode eighteen. That keeps the research API from being poked by anything that is not meant to talk to it.

  • JSON only; GET / is 404
  • Every handler behind RequestGuard
  • Details in episode 18

Role: Research

On startup the portal registers itself with the role research. That role matters for safety: the research process is not the one that holds broker sessions or places orders. It reads history from the local store and runs simulated venues, which is exactly what a backtest needs.

  • Registers with role research
  • Reads the local history store
  • No broker connection needed

Submitting a Backtest

POST /api/backtests

A backtest starts with a POST to slash api slash backtests. The body is a spec: strategy, symbols, dates and optional overrides. Before anything runs, the portal pushes it through a chain of four stages, and any stage can reject it with a clear error. Cheap checks come first, so a bad request fails in milliseconds rather than after an hour.

  • Four checks, then submit
  • Cheap checks first
  • Fail fast with clear errors

parseBacktestSpec and Merge-Patch

parseBacktestSpec reads the spec. The venue, risk and pricing sections are merge-patched over the portal defaults. Merge patch means a key you send replaces the default for that key, and keys you omit keep their default values. So sending only a slippage change leaves capital and costs alone. If the strategy uses derived instruments, such as options, the spec must name a stored underlying.

  • Request merge-patched OVER defaults
  • Omitted keys keep defaults
  • Derived needs a stored underlying

buildEngineConfigJson

Next, buildEngineConfigJson turns the spec into a real engine config. Instruments come from the provider's instrumentTemplate. There is exactly one store source, the venue is a simulated venue, and runSeconds is set to maxJobSeconds so a runaway job has a ceiling. This is the same three-file run config idea from earlier episodes, assembled in memory.

  • Instruments from instrumentTemplate
  • One store source, simulated venue
  • runSeconds = maxJobSeconds

Validation Constructs the Alphas

validateAgainstRegistries does something stricter than checking names. It actually constructs each alpha from its parameters. If a parameter is missing or out of range, the constructor complains now, at submit time, instead of inside a worker minutes later. Then a coverage check confirms the store really holds bars for the requested symbols and dates.

  • Constructs each alpha for real
  • Errors surface at submit time
  • Coverage check against the store

202, 429 or 409

Then JobManager dot submit decides the answer. A 202 means accepted, and you get an id such as bt, dash, 20261026, dash, 101500, dash, 3fa2: a date, a time and a short suffix. A 429 means the queue is full at 32. A 409 means the request has conflicting keys. Each code tells the caller exactly what to do next.

  • 202 accepted with id
  • 429 queue full
  • 409 conflicting keys

The Job Manager

A Worker Pool Per Job Kind

JobManager keeps one worker pool per job kind. Backtests get two workers, downloads get one. Each kind has its own queue guarded by a condition variable, so idle workers sleep instead of spinning, and a slow download can never starve a backtest.

  • One pool per job kind
  • Condvar queues, no spinning
  • Downloads cannot starve backtests

Why a Condition Variable

A worker waits on its condition variable with a predicate: wake when the queue is non-empty or the manager is shutting down. Submit pushes a job and notifies one worker. The predicate guards against spurious wakeups, and the shutdown flag lets every worker drain out cleanly when the portal stops.

  • Wait on predicate
  • Notify one worker per job
  • Shutdown flag drains workers

JobContext: Progress and Cancellation

Each running job receives a JobContext. It offers progress, taking a fraction and a message, and cancelled, which the job can poll. A job can also install a stop handler. When you cancel, the manager flips the flag and calls that handler, which for a backtest calls engine dot requestStop.

  • progress(fraction, msg)
  • cancelled() polled by the job
  • Cancel calls engine.requestStop()

Limits Protect the Machine

Notice how the limits interlock. Two workers cap CPU use, a queue of 32 caps memory held by waiting specs, and 200 retained results cap disk. Each limit turns an unbounded problem into a bounded one, and each has a visible failure, like the 429, rather than a slow degradation.

  • Workers cap CPU
  • Queue cap bounds memory
  • Retention cap bounds disk

Running a Job

What runBacktest Assembles

When a worker picks up a job it calls runBacktest. This builds an Engine from the config, attaches BacktestMetrics from episode fourteen, a FillRecorder that remembers fills up to the storage cap, and a ProgressReporter that feeds the JobContext. The engine then runs exactly as it would in any other mode.

  • Engine + BacktestMetrics
  • FillRecorder + ProgressReporter
  • Same engine as every mode

The Result JSON

The result is a JSON document. It holds the metrics, a decimated equity curve and drawdown curve, price series, the stored fills, any dataWarnings, and a partial flag. The partial flag is honest bookkeeping: if the job was stopped or cut short, the result says so instead of passing itself off as complete.

  • Metrics, equity, drawdown, prices
  • Fills and dataWarnings
  • partial flag for cut-short runs

Atomic Save: Tmp Then Rename

The result file is saved atomically. The portal writes to a temporary file, then renames it onto the final name. A rename within one directory is atomic on POSIX filesystems, so a reader sees either no result or the whole result, never half a file. It is the same idea as the history store from episode four.

  • Write to temp file
  • Rename onto final name
  • Readers never see half a file

A Log Per Job

Each job also gets its own log. The logging sink is thread-scoped: the worker installs it for its own thread, so two jobs running at the same time write to separate files without mixing lines. When something looks wrong in a result, you open that job's log and read exactly what the engine said.

  • Thread-scoped log sink
  • Two jobs, two separate logs
  • Debug a result by reading its log

Provably Offline Sweeps

Why a Sweep Tool

One backtest answers one question. A parameter sweep asks it many times. For that, Kestrel ships tools slash sweep slash sweep dot py, written with only the Python standard library. It drives the portal over HTTP, so a sweep uses exactly the same job pipeline we just saw.

  • tools/sweep/sweep.py
  • Python standard library only
  • Drives the same portal pipeline

Its Own Portal on 8097

The sweep does not use your running portal. It starts its own on 127.0.0.1 port 8097. It blanks every environment variable starting with FYERS underscore, so no credential can leak in. And it points KESTREL underscore DATA underscore ROOT at a fresh directory containing only the public symbol masters and no token.

  • Own portal on 127.0.0.1:8097
  • Every FYERS_* variable blanked
  • Fresh data root, no token

Pull the Plug

Finally it sets the HTTP proxy variables to 127.0.0.1 port 9. Nothing listens there, so any outbound call a library tries to make fails at once. Research is not merely supposed to be offline. It is provably offline, because the first stray network call would crash loudly instead of quietly fetching data.

  • HTTP proxies set to 127.0.0.1:9
  • Any outbound call fails at once
  • Offline is enforced, not hoped

Offline Means Reproducible

This matters beyond security. If a backtest could fetch fresh data, two runs a week apart might differ for reasons you cannot see. With a fixed local store and no network, the same spec gives the same numbers. Episode sixteen builds on exactly this property.

  • Same store, same spec
  • Same numbers every time
  • Foundation for episode 16

The Sweep Spec

The Spec File

A sweep is described by one spec. It names the strategy, provider and symbols, a baseParams object, and a grid. It has start and end dates, where end is exclusive, plus costs, a walkForward block, a score block, topN, and metricsResampleSec, which defaults to 86400, one day.

  • baseParams plus grid
  • end is exclusive
  • metricsResampleSec 86400

Grid and Constraints

The grid is a cartesian product. If fast takes three values and slow takes four, that is twelve candidates. Constraints, written as a left side, an operator and a right side, prune nonsense. For instance, requiring fast to be less than slow removes every pair where the order is backwards before any job is submitted.

  • Grid is a cartesian product
  • 3 x 4 gives 12 candidates
  • Constraints prune before running

Costs and Resampling

Costs are part of the spec, so every candidate pays the same fees and slippage. The metrics resample step is part of it too, and that detail matters later. A Sharpe ratio computed from daily returns and one from hourly returns are measured on different scales, so a sweep fixes one step for all candidates.

  • Same costs for every candidate
  • One resample step for all
  • Sharpe depends on the step

Walk-Forward Folds

The Idea of a Fold

Walk-forward splits time into folds. In each fold you train on an earlier window and test on the window right after it. You pick parameters using only the training window, then look at how those parameters did on the test window, which they never saw.

  • Train on the past
  • Test on the window right after
  • Parameters chosen on train only

Sliding Windows, With Numbers

Take a twelve month train, a three month test, stepping three months across two years. Fold one trains on months zero to twelve and tests on twelve to fifteen. Fold two trains on three to fifteen and tests on fifteen to eighteen. Fold three is six to eighteen, then eighteen to twenty one. Fold four is nine to twenty one, then twenty one to twenty four.

  • Train 12, test 3, step 3
  • Four folds over 24 months
  • Test windows tile months 12 to 24

Anchored and Truncated Folds

With anchored set to true, training always starts from day one and simply grows. The step defaults to the test length. If the final test window runs past the data, the fold is kept only when the truncated test covers at least minLastTestFraction of a full one. That stops a two day test from counting as a fold.

  • Anchored trains from day one
  • Step defaults to test length
  • Short last test needs minLastTestFraction

What the Report Gives You

For each fold, the best training score picks the parameters. The report then gives those parameters' test metrics and their rank on the test window. The out-of-sample figure compounds the chosen test returns end to end. Outputs are results dot csv, walkforward dot csv, top underscore full dot csv and summary dot json.

  • Train score picks the params
  • Report shows their test metrics and rank
  • outOfSample compounds test returns

Honest Scoring

The Score Function

Which parameters are best? That needs a number, and sweep dot py defines it in score underscore of. This is simplified Python. A failed run, zero trades, or a missing Sharpe returns None, meaning not eligible. The profit factor gate does the same. Then the trade count scales the Sharpe through a factor f.

  • Gates return None
  • f = min(1, trades / minTrades)
  • Positive scaled down, negative up

Shrink Good, Amplify Bad

Look at the last line. With few trades, f is below one. A positive Sharpe is multiplied by f, so it shrinks. A negative Sharpe is divided by f, so it grows more negative. Thin evidence can never help a candidate, whichever direction it points.

  • Positive: multiply by f
  • Negative: divide by f
  • Few trades never help

Worked Example

Let minTrades be 20. Candidate A has a Sharpe of 1.2 from only 10 trades. So f is 0.5, and the score is 0.6. Candidate B has a Sharpe of 0.9 from 40 trades. Here f is capped at 1, so the score stays 0.9, and B wins. Candidate C has a Sharpe of minus 0.4 from 10 trades. Dividing by 0.5 gives minus 0.8. These numbers are illustrative.

  • A: 1.2, 10 trades gives 0.6
  • B: 0.9, 40 trades gives 0.9, wins
  • C: -0.4, 10 trades gives -0.8

The Minimum Profit Factor Gate

The second gate handles profit factor, the ratio of gross profit to gross loss. If it is undefined but there were losing trades, the candidate is rejected. If it falls below minProfitFactor, which defaults to zero, it is rejected too. The default of zero rejects nothing by itself, but you can raise it. The defaults here are minTrades of 20 and minProfitFactor of 0.

  • Undefined PF with losers: rejected
  • Below minProfitFactor: rejected
  • Defaults: minTrades 20, PF 0

Pitfalls and Recap

Pitfall: Choosing on Test Data

The first pitfall is choosing parameters on test data. If you look at test results, then adjust the grid and rerun, the test window quietly becomes training data. Walk-forward protects you only while the choice uses training scores alone. The report shows test metrics for the chosen parameters, and it should be read, not optimised against.

  • Select on train only
  • Looking at test then tuning leaks
  • Read test results, do not optimise them

Pitfall: Mixed Resample Steps

The second pitfall is comparing Sharpe ratios computed at different resample steps. A daily Sharpe and an hourly Sharpe are not the same unit. That is why the spec carries one metricsResampleSec for every candidate. Compare numbers only when they were produced with the same step.

  • Different steps, different units
  • One metricsResampleSec per sweep
  • Compare like with like

Pitfall: High Sharpe, Few Trades

The third pitfall is trusting a high Sharpe from few trades, which the score function penalises. There is also a quieter limit. Walk-forward here has no separate warm-up, so each test window starts cold, with indicators and positions empty. Early test bars may behave differently than they would in a continuous run, and the report cannot hide that.

  • Few trades make fragile Sharpe
  • No separate warm-up
  • Each test window starts cold

Recap

To recap: kestrel_portal bounds everything with explicit limits and validates a spec by really constructing the alphas. JobManager runs worker pools with condition variable queues, progress and cancellation, and results are saved atomically. The sweep tool runs its own offline portal, then walk-forward folds choose on training and report on test, scored so that few trades never help.

  • Bounded, validated job pipeline
  • Atomic results, per-job logs
  • Offline sweeps, honest folds

Next Episode and Subscribe

This is educational material, not financial advice, and none of these numbers claim that any strategy works. Next time, episode sixteen: proving determinism with golden tests, and the run archive.

  • Next: golden tests and run archive
  • Educational only, not advice
  • Subscribe for episode 16