vike
Backtesting

Runs on disk

What a finished run leaves under user_data/runs — a common manifest every producer writes identically beside a report only its kind understands — and how the report's Sharpe is annualized from the profile's interval.

A backtest that only prints has no history: nothing to list, nothing to compare a second run against, nothing a UI could show. crates/vike-model/src/runs.rs is where that stops being true. Every finished single run gets one directory under <project>/user_data/runs/ — the vike_model::state_path::RUNS_SUBDIR location — holding two files:

user_data/runs/<run_id>/
  report.json      this run's BacktestReport — the same JSON `backtest --json` prints
  manifest.json    the common, kind-agnostic manifest

The report is the kind-specific half; only its file name is common. The manifest is the load-bearing half, and its shape is an argument.

Why the manifest is common

A listing must be able to render a row without a parser per kind. Run kinds differ in what their results contain, not in what a listing needs to say about them, so those fields sit at the top level of RunManifest, identical for every producer, and everything kind-specific nests below them in detail:

fieldmeaning
run_idthe directory name, duplicated into the file so a copied manifest still says which run it is
kind"backtest" here — the one field a reader may branch on; a plain string because the set of kinds grows in crates that cannot see each other
produced_bythe binary, spelled literally: "backtest", not the package name
started_at, finished_atRFC-3339 UTC to the second; finished_at is read before printing, so it measures the run and not the terminal
git_shaalways serialized, null included, so "could not name a build" is distinguishable from "written before the field existed"; backtest writes null because it takes no build-info dependency
configpath as the operator spelled it on the command line — deliberately not canonicalized, since that is what they can paste back — and name, the profile's label
detaileverything kind-specific, nested so a reader that does not recognise kind still renders a complete row

A kind-specific field placed beside those is the defect the shape exists to refuse: every reader would then need to know which kind it holds before reading the first key. There is no constructor and every field is public, so a new common field stops every producer compiling until it decides what to put there.

The write order is a completion marker

write_run writes the report first and the manifest last. The report is the irreplaceable half, so a disk that fills between the writes costs the metadata rather than the result — and the order hands a reader a completion marker: a directory holding manifest.json finished writing, so a listing can skip a half-written run without a lock file or a rename dance.

The reader honours that. read_manifest's RunReadError::Missing is deliberately distinct from RunReadError::Read: a missing manifest means a run being written right now or a process that died between the two writes; an unreadable one is a file that exists and cannot be opened. A listing renders them as different rows because they have different fixes — wait, versus go and look. The reader lives beside the writer because a reader written up the dependency graph would be a second definition of a common manifest, the one outcome the shape cannot survive. crates/vike-studio-core/src/listing.rs's list_runs is its first consumer.

The run id, and why the filesystem guarantees it

The id is <unix-seconds>-<pid>-<seq>: seconds say when, the pid says which process, seq breaks the rest. None of the three is the guarantee. create_run_dir mints the id by creating the directory — create_dir is atomic and fails with AlreadyExists rather than opening what is there, so the first creator owns an id and every later one advances to the next seq. The id is not checked and then used; creating is the check. It gives up after 1,000 attempts, which in practice means a runs directory refusing creation rather than a flood.

Persisting is additive, never a new way to fail

crates/vike-backtest/src/backtest_cli.rs's persist_run runs after the report has reached stdout and never changes the exit code; a run whose directory cannot be created has still computed its numbers, and the failure is named on stderr. Only the single-run path persists — a sweep, a euler refinement or a TPE run answers with a SweepReport, a different run kind, and writes nothing. The runs directory is resolved by the binary through vike_model::state_path::user_runs_dir, so a run cannot land in one project while its strategy was read from another.

One residual is declared on persist_run: VIKE_USER_DATA_DIR redirects the user_data root for the indicator load but does not move the runs directory. Time, like the path, is an input — nothing in the module reads a clock — and a second producer has already taken the intended branch: crates/vike-studio-core/src/study_run.rs's run_study persists a study run by calling create_run_dir and write_run.

The report, and its annualization

report.json is harness::BacktestReport, a re-export of vike_analytics::report::BacktestReport — the struct lives outside the feature gate so a DataFusion-free consumer need not reassemble its own copy. Its fields are on the report; the one piece that stays in the harness is crates/vike-backtest/src/harness/report.rs's periods_per_year, because it needs the profile.

Sharpe annualizes by sqrt(periods_per_year), where the count must be the number of return observations a year produces — one per bar. This used to return the daily 252 for every interval, so a one-minute run was annualized as though its bars were daily, understating every intraday Sharpe by sqrt(1440) ≈ 37.9x — silently, because the number still looked like a Sharpe — and making --rank-by sharpe incoherent across a sweep whose points differ in interval. Now the daily anchor is preserved exactly ("1d" returns DAILY_PERIODS_PER_YEAR, so no daily report moves by a bit) and every other interval scales off it: 252 · (86_400_000 / interval_ms), so 1h gives 6,048 and 1m gives 362,880. Two limits are stated rather than hidden: 252 is an equity convention and these markets trade every day, so 365 would be defensible, but changing it would move every existing daily report; and a tick profile still gets DEFAULT_PERIODS_PER_YEAR, because a tick stream has no fixed period. An unparseable interval falls back to the default rather than fabricating a scale.

On this page