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 manifestThe 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:
| field | meaning |
|---|---|
run_id | the 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_by | the binary, spelled literally: "backtest", not the package name |
started_at, finished_at | RFC-3339 UTC to the second; finished_at is read before printing, so it measures the run and not the terminal |
git_sha | always 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 |
config | path 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 |
detail | everything 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.
The harness registry
What strategy.name may say — a name-to-boxed-strategy lookup over SimBroker that is the simulator half of a two-part registry, with seven names a live daemon cannot resolve and the written reason for each.
The portfolio engine
The one event engine — aligned per-symbol bars stepped on one shared cash account, orders filling at the next open, a fixed per-step order of gates and settlements, and a tick path that k-way-merges recorded events.