# Build with AI (/docs/ai)
Vike ships two [Model Context Protocol](https://modelcontextprotocol.io/) servers. They know
nothing about each other, and one MCP client can hold both at once.
**[Data API — 31 tools](/docs/ai/data)** — `https://vike.io/mcp`, remote HTTP. Vike runs it; your client
holds a URL and a credential from your Vike account (an `X-API-KEY`, or an OAuth connection for
clients that register themselves). Wallets, tokens, Polymarket and on-chain
transfer alerts. Every call is metered against one hourly budget.
**[Trader — 17 tools](/docs/ai/trader)** — `vike-cli mcp`, a local stdio subprocess your client
launches. No Vike account is involved: it talks to services *you* run — a `vike-datahub` for
backtests, a `vike-tradehub` node for live state and orders — authenticated with your own daemon's
HMAC node keys. Rhai strategy authoring, backtests, sweeps, walk-forwards, and order writes behind
a mandatory preview gate. Nothing is metered.
The difference to carry into setup: one is a URL you point a client at, the other is a command the
client runs. They are configured in different places, and their credentials are different kinds of
thing — an account key versus the keys of a daemon on your own machine.
⚠ Nothing in the navigation links here. The sidebar's product switcher moves between the two
servers directly, so this page exists only for a saved URL. Follow one of the two links above.
# Changelog (/docs/changelog)
The two products version independently and change for different reasons, so they keep separate
records. The switcher in the sidebar moves between them.
## Data API [#data-api]
A **service**, so it changes underneath you: `/v1` routes added and removed, MCP tools added and
fixed, response fields that appeared or changed meaning, and the auth and metering rules on the
bulk archive. There is one published version prefix and routes have been removed *inside* it, so
entries that require a caller to change something are marked **⚠ BREAKING**.
[Read the Data API changelog](/docs/changelog/data)
## Trader [#trader]
A **binary you install**, so nothing changes until you upgrade. One section per released version,
newest first, with what a user gets and what changes behaviour on a box that is already running.
[Read the Trader changelog](/docs/changelog/trader)
The product repository is private. Entries carry a PR number and a commit sha so they can be
quoted in a support request, but there is nothing to click — which is worth saying once here
rather than letting you discover it a link at a time.
# Authentication (/docs/data/authentication)
Vike's data planes accept two kinds of credential. All three surfaces authenticate the same way, and
try them in a fixed order:
1. `Authorization: Bearer vk_oat_...` — an OAuth 2.1 access token.
2. `X-API-KEY: vk_...` — an API key.
An invalid bearer token **falls through** to the API-key header rather than failing outright, so a
request may carry either or both. If neither validates, the request is rejected.
| Surface | Credential |
| ---------------------------------- | ------------------------------------------------------------------------------- |
| `https://vike.io/mcp` | either — OAuth for desktop clients, `X-API-KEY` for everything else |
| `https://data.vike.io/v1/...` | `X-API-Key` header |
| `https://data.vike.io/archive/...` | `X-API-Key` header (the `manifest.json` index and `/archive/samples/` are open) |
The credential always travels in a header. There is no query-string form, and adding one to a URL
authenticates nothing.
## API keys [#api-keys]
Create keys at [vike.io/account?tab=api-keys](https://vike.io/account?tab=api-keys). A key is the
literal prefix `vk_` followed by 40 bytes of cryptographic randomness, so match on the prefix if you
need to recognise one, and never on its length or shape beyond that.
**Up to 5 active keys per account.** Creating a sixth is refused with a 400. The count is of
*active* keys, so deactivating or deleting one frees a slot.
Every key today has identical reach. There are no scopes to choose between and no way to narrow a
key to a subset of the API, so a key is read access to everything your account can reach.
### Keys are recoverable [#keys-are-recoverable]
Unlike most API products, Vike stores an encrypted copy of the full key at creation, so you can read
it back later from the account page rather than losing it the moment the dialog closes.
The exception is keys created before that behaviour existed: those have no stored copy, and the
reveal endpoint answers with a 410 telling you to create a new one. If you are holding a key old
enough to hit that, rotating it is the fix.
### Using one [#using-one]
```sh
curl -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/v1/onchain/tokens/search?q=USDC&limit=3"
```
The header name is matched case-insensitively, so `X-API-KEY` and `X-API-Key` both work.
The hourly rate limit is counted **per key**, not per account — so a second key is a legitimate way
to keep a bulk archive job from starving an interactive MCP client. OAuth tokens work the other way
round and share one pooled counter per user. [Rate limits](/docs/data/rate-limits) has the details.
## OAuth 2.1 [#oauth-21]
Claude Desktop and other clients that support Dynamic Client Registration
([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) need no API key at all. Vike registers
the client automatically; you log in once in a browser tab and approve.
Discovery metadata is published at
`https://vike.io/.well-known/oauth-authorization-server`:
| Field | Value |
| ------------------------ | --------------------------------- |
| `issuer` | `https://vike.io` |
| `registration_endpoint` | `https://vike.io/oauth/register` |
| `authorization_endpoint` | `https://vike.io/oauth/authorize` |
| `token_endpoint` | `https://vike.io/oauth/token` |
| `scopes_supported` | `mcp:tools` |
Access tokens are prefixed `vk_oat_` and go in an `Authorization: Bearer` header. Connected apps are
listed — and can be revoked — under **Account → API Keys → Connected Apps**.
[Getting started](/docs/data/getting-started) walks through the Claude Desktop connector flow step by
step.
## Credential decisions are cached [#credential-decisions-are-cached]
The verdict of a credential check is cached briefly, keyed on a hash of the credential and never on
the credential itself. Two windows matter to you as a user:
* **A valid credential is trusted for up to 45 seconds** before it is re-checked against the
account record. This is also the worst case for **revocation**: a deleted or deactivated key, or a
revoked OAuth token, can keep working for a few seconds afterwards. It is a deliberate trade —
the blast radius is a few seconds of extra reads on a data API — but it means revocation is not
instantaneous, and any page that says it is is wrong.
* **An invalid credential is remembered as invalid for up to 8 seconds.** If you just fixed a typo'd
key, give it a few seconds before concluding the new one is broken too.
A check that fails for an *infrastructural* reason rather than a credential one is deliberately
never cached, so a transient outage cannot turn into a multi-second rejection of a credential that
is actually valid.
## Keeping a key safe [#keeping-a-key-safe]
* Put it in an environment variable or a secret store, not in source control. It grants read access
to your whole quota.
* Rotate by creating the new key first, switching over, then deleting the old one — deletion takes a
few seconds to propagate through the cache above.
* If a key leaks, delete it. A deleted key stops authenticating within the 45-second window.
Rejections and their exact wire shapes are on [Errors](/docs/data/errors) — including the reason an
unauthenticated MCP call comes back as HTTP 200 rather than 401.
# Errors (/docs/data/errors)
Every Vike data surface answers failures with an HTTP status code, and usually a JSON body. The
status code is the part you should branch on; the body shape varies by which hop rejected you.
## Status codes [#status-codes]
| Code | Meaning |
| ------- | ------------------------------------------------------------------------------------------------------- |
| **200** | Success — *and* an MCP auth failure. See [the MCP exception](#mcp-answers-auth-failures-with-http-200). |
| **400** | Your input was rejected: an unknown market, a malformed identifier, both identity forms at once. |
| **401** | Invalid or missing credential — on `/v1` and on the archive alike. |
| **404** | No such route or file: a missing archive object, or a path that matches no endpoint. |
| **429** | Hourly rate limit exceeded. Carries `Retry-After`. |
| **503** | An upstream data-store failure. Not your fault; retry. |
Two properties worth relying on:
* **A failed query is never a 200 with an empty body.** An upstream failure comes back as a 503
rather than an empty list, deliberately — a silent empty result is indistinguishable from "no
data", which is how bad backfills get built on top of an outage.
* **There is no 403 today.** There are no scopes to be refused: every valid credential has the same
reach across the whole API. The code exists in the error vocabulary for the day one appears.
## The JSON envelope [#the-json-envelope]
The API emits one envelope shape:
```json
{
"error": {
"code": "RATE_LIMITED",
"message": "Hourly rate limit exceeded",
"retry_after": 60
}
}
```
`retry_after` appears only on 429. The archive's own 401 carries a `docs` field instead:
```json
{
"error": {
"code": "UNAUTHORIZED",
"message": "invalid or missing credential — see https://vike.io/docs/data/ for how to authenticate",
"docs": "https://vike.io/docs/data/"
}
}
```
The envelope above is what the API emits. It is not always what reaches you.
A credential rejection on `/v1` or on the archive is produced ahead of the API — the request is
turned away before anything can build that envelope — so its body is not the shape above. Measured
on 2026-09-02, a keyless request to a `/v1` path or to `https://data.vike.io/archive/` returns a
correct **401** whose body is a stock **HTML** error page.
Do not assume a 401 carries JSON.
The status code and the `Retry-After` header are correct on every path and always have been. A
client that parses the body to decide what happened will break on a hop it did not expect; a client
that reads the status code will not.
## 429 and `Retry-After` [#429-and-retry-after]
A rate-limit trip returns **429** with **`Retry-After: 60`** on every surface. Every layer that can
return one quotes the same 60, deliberately, so two hops cannot disagree.
The value is an advisory back-off hint, not a promise. The hourly window slides continuously and the
counter refreshes on its own schedule, so "when will capacity exist" has no per-request answer
anyone stores. Sixty seconds is a sane hint chosen over a false precision.
What a client should do:
```python
r = requests.get(url, headers={"X-API-Key": key})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 60)))
# ...then retry. Do not retry immediately, and do not retry in a tight loop:
# a rate-limited request is itself metered.
```
A rejected request is recorded and counted like any other, so retrying without backing off keeps you
rejected.
Almost every 429 in practice has one cause — see [Rate limits](/docs/data/rate-limits), particularly the
section on Range requests against the archive.
## MCP auth failures are HTTP 401 [#mcp-auth-failures-are-http-401]
`https://vike.io/mcp` speaks JSON-RPC 2.0, and both failure modes carry a JSON-RPC error object in
the body as well as an HTTP status:
| Failure | HTTP status | Body |
| ----------------------------- | ---------------------------- | ------------------------------------ |
| Invalid or missing credential | **401** + `WWW-Authenticate` | JSON-RPC error object, code `-32000` |
| Hourly rate limit exceeded | **429** + `Retry-After` | JSON-RPC error object, code `-32000` |
An unauthenticated call returns this, with HTTP 401 and `WWW-Authenticate: Bearer resource_metadata="https://vike.io/.well-known/oauth-protected-resource"` (the MCP authorization spec — an OAuth client starts discovery from that URL):
```json
{"jsonrpc": "2.0", "id": null, "error": {"code": -32000, "message": "Invalid or missing API key"}}
```
A rate-limited call returns HTTP 429, and puts the envelope's fields in the JSON-RPC error's `data`
member so both kinds of client can read it:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32000,
"message": "Hourly rate limit exceeded",
"data": { "code": "RATE_LIMITED", "retry_after": 60 }
}
}
```
Other JSON-RPC errors (`-32700` parse error, `-32601` unknown method or tool) still arrive at HTTP
200, so if you are writing a raw HTTP client against `/mcp`, **checking `response.ok` is not
enough** — you must also look for an `error` member in the body.
## Getting a 401 you did not expect [#getting-a-401-you-did-not-expect]
Three causes, in rough order of likelihood:
* **The header name.** The archive and `/v1` want `X-API-Key`. An OAuth token goes in
`Authorization: Bearer`. A key in a query string is never read.
* **The key is inactive or was deleted.** Deletion is a soft delete; an inactive key authenticates
no better than a deleted one.
* **You just changed something.** Credential decisions are cached for up to 45 seconds (positive)
and 8 seconds (negative), so a newly fixed key can take a few seconds to start working and a
revoked one a few seconds to stop. See [Authentication](/docs/data/authentication).
# Getting started (/docs/data/getting-started)
There are two ways to start, depending on what you are building.
* **An AI client** — Claude, Cursor, Gemini CLI, Copilot — connects to the MCP endpoint and gets the
whole tool surface conversationally. Start at [Connect an MCP client](#connect-an-mcp-client).
* **Code** — a script, a notebook, a backfill — uses the REST API or the bulk archive with an API
key. Start at [Get an API key](#get-an-api-key).
Claude Desktop needs no API key at all; every other path does.
## Claude Desktop — no API key needed [#claude-desktop--no-api-key-needed]
Claude Desktop supports OAuth 2.1 with Dynamic Client Registration, so Vike registers your client
automatically. There is no key to copy, no JSON file to edit and no Node.js involved.
1. Open **Settings → Connectors → Add custom connector**
2. Name: `Vike`
3. URL: `https://vike.io/mcp`
4. Leave the OAuth Client ID and Secret fields **empty**
5. Click **Add**. A browser tab opens — log into Vike and click **Allow**
6. The Vike tools appear under the hammer icon in the chat input
Revoke access at any time under **Account → API Keys → Connected Apps**.
## Get an API key [#get-an-api-key]
Every other client, and all REST and archive access, uses an API key.
1. Create an account at [vike.io](https://vike.io)
2. Go to [Account → API Keys](https://vike.io/account?tab=api-keys)
3. Enter a label (for example `Cursor`) and click **Create Key**
4. Copy the key — it starts with `vk_`
Up to **5 active keys** per account. Unlike most API products, Vike stores an encrypted copy, so you
can read a key back from that page later rather than losing it when the dialog closes.
Keep it in an environment variable for everything below:
```sh
export VIKE_API_KEY="vk_..."
```
## Connect an MCP client [#connect-an-mcp-client]
The endpoint is `https://vike.io/mcp` for every client. What follows is the short version;
[Client setup](/docs/ai/data/setup) has a page per client, with troubleshooting. Vike's second MCP
server — the local `vike-cli mcp` for the trading platform — is set up separately, on
[Trader / Client setup](/docs/ai/trader/setup).
Use `https://vike.io/mcp`. `data.vike.io/mcp` reaches the same server with the same tools and
accepts the same API key, but OAuth clients such as Claude Desktop should use `vike.io/mcp`.
### Claude Code [#claude-code]
One command, user-scoped. Restart any open session afterwards.
```sh
claude mcp add --transport http vike https://vike.io/mcp \
--header "X-API-KEY: $VIKE_API_KEY"
```
### Cursor [#cursor]
**Settings → MCP → Add new server**, or edit `~/.cursor/mcp.json`:
```json
{
"mcpServers": {
"vike": {
"url": "https://vike.io/mcp",
"headers": { "X-API-KEY": "YOUR_KEY" }
}
}
}
```
### Gemini CLI [#gemini-cli]
```sh
gemini mcp add --transport http \
--header "X-API-KEY: $VIKE_API_KEY" \
vike https://vike.io/mcp
```
### GitHub Copilot (VS Code) [#github-copilot-vs-code]
Create `.vscode/mcp.json` in your workspace and reload:
```json
{
"servers": {
"vike": {
"type": "http",
"url": "https://vike.io/mcp",
"headers": { "X-API-KEY": "YOUR_KEY" }
}
}
}
```
### Raw HTTP [#raw-http]
JSON-RPC 2.0 over HTTP, for scripting or a one-off call:
```sh
curl https://vike.io/mcp \
-H "X-API-KEY: $VIKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
On `/mcp`, an authentication failure comes back as **HTTP 200** with a JSON-RPC error object in the
body — that is what JSON-RPC specifies. Only the rate limit uses a real HTTP error status (429).
Checking `response.ok` alone will make a rejected call look successful. See
[Errors](/docs/data/errors).
### Verify it worked [#verify-it-worked]
Ask your assistant:
> Search for PEPE token using Vike
A working connection calls the `token_search` tool and returns matching tokens.
## Vike Skills (CLI playbooks) [#vike-skills-cli-playbooks]
Skill playbooks teach an agent how to use Vike's tools well. The installer auto-detects Claude Code,
Cursor, Codex, Copilot, Gemini CLI and many more.
Preview what is available:
```sh
npx skills add vike-io/vike-cli --list
```
Install:
```sh
npx skills add vike-io/vike-cli
```
## Your first REST call [#your-first-rest-call]
The REST API is plain JSON over HTTPS at `https://data.vike.io/v1`, keyed by the `X-API-Key` header:
```sh
curl -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/v1/hyperliquid/coins/BTC/cohort-metrics?hours=6"
```
Endpoints are grouped into namespaces — `hyperliquid` (35), `onchain` (15) and `polymarket` (11),
plus the archive, the spec surfaces and one WebSocket.
[API endpoints](/docs/endpoints/data) is the per-endpoint reference: one page per endpoint, each
with its method, full path and parameters.
## Your first archive download [#your-first-archive-download]
The bulk archive is date-partitioned Parquet at `https://data.vike.io/archive`. Start with the index,
which needs no key:
```sh
curl -s https://data.vike.io/archive/manifest.json | jq '.dates[0]'
```
It lists every available date and family with row counts and byte sizes, plus the schema of each
stream. Free daily samples live under `https://data.vike.io/archive/samples/`, also keyless.
Data files need a key. Download the whole file:
```sh
curl -O -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/archive/venue=polymarket/date=2026-09-01/trades.parquet"
```
A Parquet reader pointed at a URL issues thousands of Range requests for a single file: one 1.26 GB
partition was measured producing **20,237 separate HTTP requests**. Archive downloads do not spend
your hourly budget, but each of those requests crosses the network at a per-connection cap, and a
remote reader pays that cost again on every query. Download the file with `curl -O` (resume with
`-C -`) and read the local copy. [Rate limits](/docs/data/rate-limits) has the measurements.
## Limits [#limits]
**20,000 requests per rolling hour**, counted per API key. MCP and REST calls both draw on that one
counter; archive downloads are authenticated and recorded, but never charged against it. OAuth
tokens are pooled per user instead of per key.
Older documentation quoting *100 calls/hour* or *2,000/hour* is out of date. Recent calls per key are
visible under **Account → API Keys**.
## Next [#next]
# Vike Data API (/docs/data)
The Vike Data API serves market and on-chain data across three families — on-chain, Hyperliquid
and Polymarket — through three access planes: an MCP endpoint for AI agents, a JSON REST API, and a
bulk Parquet archive for research-scale downloads. It is free to start.
## Three ways in [#three-ways-in]
| Plane | Where | Auth |
| ---------------- | ------------------------------ | ------------------------------------------------------------------------------ |
| **MCP** | `https://vike.io/mcp` | connect from Claude or any MCP client |
| **REST** | `https://data.vike.io/v1` | `X-API-Key` header |
| **Bulk archive** | `https://data.vike.io/archive` | `X-API-Key` for data files; the `manifest.json` index and the samples are open |
MCP and REST share one hourly budget per credential; archive downloads are authenticated but not
charged against it — see [Rate limits](/docs/data/rate-limits).
### MCP [#mcp]
Point an MCP client at `https://vike.io/mcp` and the whole tool surface is available
conversationally — token screening, wallet tracing, DeFi position decoding, Polymarket smart money,
and the rest. Claude Desktop connects over OAuth with no API key at all. All 31 tools are
documented under [Data tools](/docs/ai/data), and [Client setup](/docs/ai/data/setup) walks each client.
### REST [#rest]
Plain JSON over HTTPS, keyed by an `X-API-Key` header:
```sh
curl -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/v1/hyperliquid/coins/BTC/cohort-metrics?hours=6"
```
### Bulk archive [#bulk-archive]
Research-scale history as date-partitioned Parquet with Hive-style paths, for example the Polymarket
set:
```
/archive/venue=polymarket/date=YYYY-MM-DD/{book_events,trades,l1_quotes}.parquet
```
An open `manifest.json` (no key needed) lists available dates, per-stream schemas, row counts and
sizes, and there are free daily samples under `/archive/samples/`. A second, additive layout groups
the crypto up/down families by `asset` and `tenor` into much smaller files, so a single-family
consumer need not take a whole token-sorted day.
The archive supports Range requests and every Parquet reader will use them, so a remote read of one
file becomes thousands of separate HTTP requests over a capped connection — and repeats them on
every query. Downloads do not spend your hourly budget, but a remote read is far slower than
fetching the file with `curl -O` and reading the local copy. [Rate limits](/docs/data/rate-limits) has
the measurements.
## The three families [#the-three-families]
* **onchain** — token screening, transfers and holders, wallet summaries, tracing and PnL, ENS.
* **hyperliquid** — perp top traders, position matching, per-coin cohort metrics, and Hyperliquid's
own OHLCV candles.
* **polymarket** — markets and detail, smart money, wallet activity, plus the full-L2 bulk archive
above.
## Start here [#start-here]
# Rate limits (/docs/data/rate-limits)
Every MCP tool call and every REST API call spends one unit of the same hourly budget. Archive
downloads are authenticated on every request and recorded on every request, and charged on none.
There is one number, one counter, and one exclusion.
## The number [#the-number]
**20,000 requests per rolling hour.** One value applies to every credential — there are no plan
tiers, no per-endpoint ceilings and nothing to configure.
This limit was raised on 2026-07-29. Documentation and product pages previously stated *100
calls/hour* for MCP and *2,000/hour* for the REST API; both are stale by an order of
magnitude or more.
Separately, **archive downloads stopped spending this budget on 2026-09-02**. Any page telling you
that a Parquet download costs you requests, or that an hour of archive downloads leaves nothing for
your MCP client, predates that change.
The window slides. What counts is your usage over the *previous* hour, measured from now, so
capacity returns continuously as old requests age out — there is no fixed reset at the top of the
hour, and waiting for one will not help you. A request is counted before it is compared against the
ceiling, so 20,000 is the number of requests *admitted* per window, inclusive.
## One counter, three surfaces [#one-counter-three-surfaces]
Every authenticated request is recorded against your credential under a label you can see in your
usage view. Two of the three surfaces also spend budget:
| You call | Recorded as | Spends the hourly budget |
| ---------------------------------- | ---------------------------- | ------------------------ |
| `https://vike.io/mcp` | `mcp` | yes |
| `https://data.vike.io/v1/...` | `api__` | yes |
| `https://data.vike.io/archive/...` | `archive_download` | **no** |
The two metered surfaces do **not** have budgets of their own — they share the single counter. An
hour of `/v1` calls leaves nothing for your MCP client: between those two, the labels separate your
usage *analytics*, not your *budget*. The archive is the one surface separated in both.
### Per key, not per account [#per-key-not-per-account]
The counter is keyed on the credential, not on its owner, so **each of your API keys gets its own
20,000**. Issuing a second key is a legitimate way to isolate a bulk job from an interactive one,
and one key tripping the limit does not affect another.
OAuth is the exception. **Every OAuth token you own shares one pooled counter**, held per user
rather than per token. Two connected Claude Desktop clients draw from the same budget; two API keys
do not.
## The archive is authenticated but not metered against your hour [#the-archive-is-authenticated-but-not-metered-against-your-hour]
Every request to `https://data.vike.io/archive/...` still validates its credential, and still
appears in the usage view on your account page. It simply never reaches the rate check. No archive
request can consume your hourly budget, and no amount of downloading can rate-limit you.
### Why a request is the wrong unit for a bulk file [#why-a-request-is-the-wrong-unit-for-a-bulk-file]
The gate in front of the archive counts **HTTP requests**. It does not see *files*, or *bytes*, or
*downloads* — and a Range request is a separate HTTP request.
A remote Parquet read fetches the footer, then each column chunk of each row group it needs, as its
own Range request. The archive actively invites this: `manifest.json` advertises
`"range_requests": true` (verified in the live manifest), and the server returns `Accept-Ranges`,
so pyarrow, DuckDB, fsspec and every other reader will use ranges by default.
Measured on the live archive: **one 1.26 GB partition produced 20,237 separate requests** — more
than the entire hourly ceiling, for a single file. Almost every rate-limit rejection recorded in
the platform's whole history was an archive download, and every one of them was a customer using
the format exactly as intended.
The meter was counting the reader's access pattern rather than the download, so the archive stopped
spending the budget. The real cost of a bulk transfer is bandwidth, which is bounded separately at
**50 MB/s per connection**.
### Download whole files anyway [#download-whole-files-anyway]
Pointing pandas, DuckDB or fsspec straight at an archive URL re-fetches the file's footer and
column chunks across the network on **every** query, at a rate capped per connection. Pull the
partition once instead.
One file is one request. Fetch it, then point your reader at the local copy:
```sh
curl -O -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/archive/venue=polymarket/date=2026-09-01/trades.parquet"
```
An interrupted transfer resumes with `-C -` rather than starting over:
```sh
curl -C - -O -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/archive/venue=polymarket/date=2026-09-01/book_events.parquet"
```
Row-group pushdown and column pruning still work against the local file; they just work against
your disk instead of across a 50 MB/s link.
### Download less, not more cleverly [#download-less-not-more-cleverly]
Full-day `book_events` partitions run 4–7 GiB. If you need one market family rather than the whole
Polymarket universe, the archive publishes a second, additive layout that groups the crypto up/down
families into much smaller files:
```
/archive/venue=polymarket/asset=btc/tenor=5m/date=2026-09-01/book_events.parquet
```
Same schema as the `date=` layout. `manifest.json` lists every `(asset, tenor, date)` triple with
its row count and byte size, so you can pick exactly the files you need before transferring a byte
— and the manifest itself needs no key at all, like the free samples under `/archive/samples/`.
## The counter is a backstop, not a meter [#the-counter-is-a-backstop-not-a-meter]
The limit is enforced approximately, and deliberately favours availability over exactness:
* **It is enforced per server process.** Requests from one client can be handled by different
processes, each keeping its own view of your count, so a client whose requests spread across them
can see an effective ceiling meaningfully above the nominal one before every process agrees it is
over.
* **It refreshes on a short interval.** Between refreshes the count is tracked locally with no round
trip, so a burst can briefly overshoot before the next refresh catches up.
* **A backend failure does not open the gate.** If a refresh fails or times out, the last known
count is reused rather than the check being skipped.
Treat 20,000 as the number you must design under, not a number you can calibrate against. Do not
build a client that runs deliberately at the edge of it.
## When you are limited [#when-you-are-limited]
You get **HTTP 429** with a `Retry-After: 60` header on every metered surface. Every layer that can
return one quotes the same 60, deliberately, so two hops cannot disagree — and it is advisory: a
sliding hourly window has no single correct answer to "when will capacity exist", so 60 seconds is a
sane back-off hint rather than a promise.
The archive cannot return a rate-limit 429 at all, because an `/archive/` request is never metered.
A missing or invalid credential there is still a **401**.
Honour the header, back off, and retry. A rate-limited request is itself recorded and itself counts,
so hammering a closed door keeps the door closed. The exact body shapes, and the one surface where a
rate-limit trip and an auth failure carry different HTTP statuses, are on [Errors](/docs/data/errors).
## Watching your usage [#watching-your-usage]
Recent calls, per key, are visible under **Account → API Keys** at
[vike.io/account?tab=api-keys](https://vike.io/account?tab=api-keys). Archive downloads appear here
like everything else — they are recorded, just not charged. Each call also carries a credit value
(0–4 for an MCP tool) recorded for accounting, but **the hourly limit counts requests, not
credits** — a 0-credit call consumes a unit of the hourly budget exactly like any other.
# Glossary (/docs/trader/glossary)
Vike Trader's vocabulary is small and precise, and most of it is enforced by a test rather than
described in prose. Each entry below names the code symbol that is the authority for the word and
links to the page that explains it. A word with more than one sense gets every sense it carries.
## Arming and mounting [#arming-and-mounting]
* **ceiling** — the per-venue arming tier in `policy.toml`'s `[venues]` table, a
`vike_config::VenueMode` ordered `paper < demo < live`, which `vike_mount::make_engine` consults
before any credential is read. It can only refuse: `VenueMode::cap` is `min`, never `max`, so
`bybit = "live"` declines to stop bybit going live rather than putting it there, and a box with
no table mounts every venue paper. The word also names `vike_config::Policy` as a whole — the
hard ceilings, the one settings type with neither an environment nor a CLI layer. →
[Risk and arming](/docs/trader/concepts/risk-and-arming), [policy](/docs/trader/reference/settings/policy).
* **arming** — taking a venue past paper. Two gates decide it, in order: the ceiling, then the
credential gate — present `{VENUE}_DEMO_*` keys spawn a real execution client, absent ones keep
the venue paper; `crates/vike-bridge-core/src/lib.rs` calls its `credentials` module the
absent-credentials-is-the-live-gate authority. → [Risk and arming](/docs/trader/concepts/risk-and-arming).
* **paper** — `VenueMode::Paper`: simulated fills against the local paper exchange, no credential
used, no order leaving the process, the default for every venue. The exchange is
`vike_paper::PaperExecutionClient`, which fills a resting book with the backtest engine's own
public fill primitives, so backtest == paper is by construction. →
[The paper exchange](/docs/trader/concepts/paper-exchange).
* **mount** — three related things. A *venue* mount is what `vike_mount::make_engine` builds: one
type-erased `ExecutionEngine` for a venue and symbol plus an optional `ReconClient`. A *strategy*
mount is a strategy on a (venue, symbol, interval) triple, whose identity
`crates/vike-core/src/strategy_state.rs`'s `mount_id_of` derives deterministically
(`binance__BTC_USDT__1m`). A *runtime* mount is one added to a running core over the wire
(`Command::MountStrategy` carrying a `MountSpec`) and recorded so it survives a restart. →
[Mounting](/docs/trader/concepts/mounting), [Accounts](/docs/trader/concepts/accounts),
[Venues](/docs/trader/venues/).
* **sidecar** — a JSON file the core writes beside its state through a temp-write, fsync and
rename: `/.json` for a strategy's durable state, `runtime_mounts.json` for
the runtime mount topology. Both sit behind the opt-in `CoreConfig::state_dir` and read
fail-open, so a corrupt one warns rather than refusing a boot. →
[Mounting](/docs/trader/concepts/mounting).
* **run** — in the live core, a `RunProfile`: the single TOML artifact that names a full runtime
assembly — event source, broker, sinks, risk and guard stack — so backtest, paper and live are
one auditable file. In backtesting, what a finished run leaves behind: one directory under
`/user_data/runs/` holding a common manifest beside a report only that run's kind
understands. `vike-run` is the crate that mounts a strategy on the production core and feeds it.
→ [Mounting](/docs/trader/concepts/mounting), [Run profiles](/docs/trader/backtesting/run-profiles),
[Runs on disk](/docs/trader/backtesting/runs-on-disk).
## Inside the core [#inside-the-core]
* **fold** — applying one event to state on the single-writer thread. `ExecutionEngine::on_event`
folds the venue stream — bare fills into the account, lifecycle events through the
`ManagedOrder` state machine — and answers with a `Fold` verdict: `Applied` when state changed,
`Dropped` for an unknown client-order-id, an illegal transition, a reconnect replay, an event
for a symbol or venue this engine does not own, or a non-finite venue number, and nothing
downstream may treat a dropped event as having happened. The fold loop is the thread the
latency gate measures. In reconciliation the verb is the same: a policy decides fold-versus-hold
per divergence. →
[The execution core](/docs/trader/concepts/execution-core), [The event bus](/docs/trader/concepts/event-bus),
[The latency gate](/docs/trader/concepts/latency-gate),
[Policies](/docs/trader/concepts/reconciliation/policies).
* **lane** — one of the ingest channels into the core, each with its own loss discipline: exec
events are lossless and back-pressure the venue task, commands `try_send` so the GUI never
blocks, market data rides a latest-wins conflation slot plus one marker; the bar lanes
(`BarSeed`/`BarClose`) and the tick lanes (quote, trade, book) are lossless too. The producer
types live in `crates/vike-exec/src/lanes.rs`, one crate below the core that drains them. Two
narrower uses: the emulator's `PriceLane` (`Last` or `Mark`) names which price series checks a
conditional, and the backtester's queue lane is the opt-in queue-position fill path. →
[Ingest lanes](/docs/trader/concepts/ingest-lanes), [The execution core](/docs/trader/concepts/execution-core),
[Emulated orders](/docs/trader/concepts/emulated-orders), [The queue lane](/docs/trader/backtesting/queue-lane).
* **snapshot** — `CoreSnapshot`, the immutable state view the core publishes to the GUI through an
arc-swap cell. The GUI is a lossy downstream observer that reads the latest on repaint and can
never back-pressure the core; the build is coalesced, never per-event. →
[Portfolio and snapshot](/docs/trader/concepts/portfolio-and-snapshot).
* **halt** — `TradingState::Halted`, under which the `RiskGate` admits only a position-covered
reduce (`vike_model::is_covered_reduce`) and denies every opening order; also the halt sentinel
file every `ExecutionClient` consults, with `crates/vike-exec/src/halt.rs` the one predicate for
what an engaged halt lets out. **Safe-state** is the halt the core enters by itself when a
handler panics: halted, a best-effort cancel of every non-terminal working order, the fault
surfaced in the snapshot, the engine never traded on again until restart. →
[Halt](/docs/trader/concepts/halt), [The execution core](/docs/trader/concepts/execution-core),
[The dead-man's switches](/docs/trader/concepts/dead-mans-switch).
* **dead-man's switch** — two of them, with opposite defaults because they read opposite evidence.
The *connection* one (`vike_core::LinkDeadManConfig`, `policy.toml`'s `link_deadman_grace_ms`,
armed when the key is absent, `0` the only off) trips on a link a venue's own bridge has reported
`Disconnected` past the grace and cancels that venue's resting orders; a market that merely closed
is disclosed `FeedStatus::Stale`, which never counts, so a close cannot reach it. The *silence*
one (`vike_core::DeadManConfig`, `deadman_timeout_ms`, off unless the key is written) trips on
ingest that has gone quiet on every venue at once and cancels everything — which is exactly why it
may not be armed by default, a closed market being silence too. They share `deadman_action`, and
under its default a trip also engages the halt. →
[The dead-man's switches](/docs/trader/concepts/dead-mans-switch).
* **journal** — the write-ahead command journal: pre-allocated mmap segments whose frames are
`[len][check][payload]`; a record failing its check is a torn tail at which reading stops. →
[The command journal](/docs/trader/concepts/journal/).
* **emulated order** — a conditional order, a fixed or trailing stop, held in the core's own
`ConditionalBook` for a venue or strategy without native support. Arming bypasses the risk gate;
the fire goes through the one live path as a plain market order, triggered by
`vike_model::order_fill_price`, the same oracle the backtest fires on. `Emulated` and `Released`
are the state machine's client-side conditional states. →
[Emulated orders](/docs/trader/concepts/emulated-orders).
## Orders and reconciliation [#orders-and-reconciliation]
* **terminal** — one of the five order states after which no further lifecycle event applies:
`Filled`, `Canceled`, `Rejected` (the venue's refusal), `Denied` (the risk gate's veto,
pre-venue) and `Expired`. `OrderStatus::is_terminal` names exactly those, and `Liquidated` is
deliberately not among them. Every order must reach exactly one terminal, and a dead venue path
must synthesize a terminal `OrderRejected` rather than let an order vanish. →
[Order lifecycle](/docs/trader/concepts/order-lifecycle), [Events](/docs/trader/concepts/events/).
* **kind** — the member name of a declared roster the runtime consults. A store kind is one
`kind=` row of `vike_data::store_kind::STORE_KINDS`, the on-disk layout authority; an order kind
is one of `vike_model::venue_caps::ORDER_KINDS` (`market`, `limit`, `stop`, `stop_limit`,
`take_profit`, the last three the trigger kinds); a divergence kind is a `DivergenceKind`
variant; a run kind is the one top-level manifest field a listing may branch on. →
[Data](/docs/trader/concepts/data/), [Orders](/docs/trader/concepts/orders/),
[Reconciliation](/docs/trader/concepts/reconciliation/), [Runs on disk](/docs/trader/backtesting/runs-on-disk).
* **divergence** — one detected local-versus-venue disagreement, carrying the venue evidence
needed to resolve it. `vike_exec::recon::diff` turns venue reports plus a `LocalView` into
divergences; `resolve` turns those plus a `ReconPolicy` into a `Recon` — events to fold, a
snapshot seed and alerts — and both are pure, with no I/O. →
[Reconciliation](/docs/trader/concepts/reconciliation/).
* **held** — a divergence a policy did not fold, kept as an operator alert awaiting confirm. A held
divergence is a state, not an event: under `quarantine` one that nothing heals is re-diffed and
re-held every pass, so the warning fires when it enters or leaves the held set and a re-raise
refreshes its row rather than appending. → [Policies](/docs/trader/concepts/reconciliation/policies).
* **wiring class** — how a venue's exec and reconcile sides are wired into a default live mount,
the one docs-facing axis no capability table encodes because it is a property of `make_engine`'s
arms rather than of any adapter. Four classes: `live-exec-recon`, `feature-gated` (the arm
compiles only under a `vike-mount` feature), `sidecar-exec-no-recon` (exec over an owned child
process, no reconcile) and `paper-only`. `crates/vike-ops/src/docs_data.rs`'s `WIRING` declares
one named row per roster venue, gated for completeness, and ships in `venues.json`. →
[Venues](/docs/trader/venues/), [Reconciliation](/docs/trader/concepts/reconciliation/).
## Data, venues and rules [#data-venues-and-rules]
* **seam** — a trait boundary two sides agree on without seeing each other.
`vike_exec::ExecutionClient` is the one trait every venue bridge implements; `HistStore` is the
unified historical-data seam, one store for bars and ticks; `DataClient` is its live mirror. →
[The ExecutionClient seam](/docs/trader/concepts/execution-client-seam),
[The history store](/docs/trader/concepts/hist-store), [The live data seam](/docs/trader/concepts/live-data-seam).
* **bridge** — a venue adapter: one crate per venue under `crates/bridges/`, built from
`vike-bridge-core`'s shared plumbing — blocking REST transport and signers, the venue-neutral WS
user-data pump, the `ExecActor` command thread, credentials, the pinned Decimal wire-format
site. → [Venues](/docs/trader/venues/).
* **roster** — `vike_model::VENUES`, the one canonical venue list: every per-venue capability
table's completeness test iterates it, so adding a venue fails every table's tests until its rows
exist, and a roster venue's row is always named even when its value equals the fallback. →
[Venues](/docs/trader/venues/), [Adding a venue](/docs/trader/developer/adding-a-venue).
* **project** — ``, the one folder holding settings and credentials
(`/settings/`), program-written state (`/settings/state`), runs and data.
`$VIKE_SETTINGS_DIR` names the `settings/` directory outright — `` is derived from it —
and wins over the walk up from the working directory; a blank value is ignored. The history
store's own root follows one shared precedence — `vike_model::store_path::resolve_store_root` —
in which anything stated outranks anything inferred. → [Settings](/docs/trader/concepts/settings),
[The history store](/docs/trader/concepts/hist-store).
* **parity fixture** — the frozen golden files under `fixtures/r0/` through `fixtures/r6/`
(there is no `r2/`), exported from the retired Python platform; every exporter was deleted, so
the committed bytes are the oracle, and what the parity suites prove is that this code has not
changed its arithmetic unnoticed. →
[The parity gate](/docs/trader/concepts/parity-gate).
* **gate** — a CI test that holds a rule the way a paragraph cannot.
`crates/vike-ops/tests/layer_gate.rs` turns "dependency direction: down only" into a merge gate
over every manifest's declared `layer`, and most rules the concepts pages state are held the same
way. → [Architecture](/docs/trader/concepts/architecture), [Developer guide](/docs/trader/developer/).
# Vike Trader (/docs/trader)
Vike Trader is a trading platform written in Rust. One workspace builds three things — a desktop
charting application, a headless live-trading daemon, and a command-line tool that doubles as a
server for AI agents — and all three sit on the same execution core, the same backtester and the
same 14 venue bridges.
Free. Source-available under FSL-1.1; every release becomes Apache-2.0 two years on.
Absent credentials are the live gate, and a per-venue ceiling in `policy.toml` is consulted before
them. A box with no credential store and no `[venues]` table mounts everything paper, whatever else
is configured. Arming is something you do deliberately, per venue — see
[risk and arming](/docs/trader/concepts/risk-and-arming).
## Start here [#start-here]
## Learn the platform [#learn-the-platform]
The concepts tree is the reference for how the thing works — the execution core and the seam every
venue implements, the write-ahead journal a session replays from, the ingest lanes, the risk gate
and the halt boundary. It is where a claim about behaviour is settled.
## Build something [#build-something]
## Run it [#run-it]
## Look something up [#look-something-up]
## What makes it one platform [#what-makes-it-one-platform]
**One execution core, three front ends.** The desktop application, the daemon and the command-line
tool are thin composition roots over the same crates. One broker-generic registry serves the
backtest simulator and the live daemon alike — but not everything that backtests can mount live:
the registry carries a per-name live verdict, and the daemon refuses a simulator-only name, or one
whose verdict says it would resolve and then never trade, when it loads the profile. The
[execution client seam](/docs/trader/concepts/execution-client-seam) is the single contract every venue
bridge implements.
**Research and paper trading cannot disagree about a fill.** The backtester's simulated fills and
the live paper exchange are built from one shared crate, so a result you get in research is
computed the same way the paper book computes it.
**The capability tables are the documentation.** Every venue page, indicator page, strategy
template and reconciliation page on this site is rendered from the same tables the runtime consults,
exported at each release. A page here cannot describe a capability the product does not have,
because the gate in the product tree fails first.
**Absent credentials are the live gate.** Nothing trades because it was configured to; it trades
because somebody armed that venue, in a file, on purpose.
# Alerts (/docs/ai/data/alerts)
Six tools for on-chain **transfer alerts**: rules that fire when a transfer of a given token
crosses a USD threshold. Unlike every other family here, these tools *write* to your account —
they are the only ones that change state.
Three things constrain what an alert can express today, all of them stated by the tools
themselves:
* **Chains `eth` and `bsc` only.** One chain per alert: passing two silently watches only the
first, with no error to tell you the second was dropped. Watching both means creating two
alerts.
* **USD thresholds only.** There is no token-quantity condition — a rule triggers on transfer
*value*, not on size in tokens.
* **One alert type.** `transfer` is the only kind the service accepts.
Alerts belong to the authenticated user, and every tool enforces ownership: you can only list,
edit or delete your own. Both credentials resolve to a user, so an API key and an OAuth token
reach the same alerts.
## `alerts_list` [#alerts_list]
**Free — 0 credits.** Lists your alert rules. Active only by default.
| Parameter | Type | Default | Notes |
| ------------------ | ------- | ------- | -------------------------------------- |
| `include_inactive` | boolean | `false` | Include paused and soft-deleted alerts |
**Returns** `{"alerts": [...]}`, one row each with `id`, `label`, `alert_type`, `chain`,
`token_symbol`, `min_value_usd`, `max_value_usd`, `cooldown_minutes`, `max_triggers_day`,
`notify_channel_ids`, `is_active` and `created_at`.
This is also how you get an `alert_id` — `alerts_edit` and `alerts_delete` both need one, and
neither accepts a label.
> List my alerts, including the paused ones.
## `alerts_list_channels` [#alerts_list_channels]
**Free — 0 credits.** Your enabled notification channels. No parameters.
**Returns** `{"channels": [...]}` with `{id, channel_type, is_verified, label}` per row.
Call it before creating an alert. An alert created against a channel you have not set up is
still created — it simply never notifies anyone, which is the failure mode worth avoiding.
> What notification channels do I have set up?
## `alerts_create` [#alerts_create]
**3 credits.** Creates a transfer alert.
| Parameter | Type | Default | Notes |
| ------------------ | ------- | ---------- | ------------------------------------------- |
| `label` | string | *required* | The alert's user-visible name |
| `token_symbol` | string | — | Ticker in UPPERCASE (`USDC`, `ETH`, `PEPE`) |
| `chain` | enum | `eth` | `eth` or `bsc`, single value |
| `min_value_usd` | number | — | Fire when transfer USD value ≥ this |
| `max_value_usd` | number | — | Fire when transfer USD value ≤ this |
| `channels` | array | — | `["email"]`, `["discord"]`, or both |
| `cooldown_minutes` | integer | `60` | Minimum gap between triggers, floor of 1 |
**Returns** `{"alert": {...}}` — the new row in the shape `alerts_list` uses, including its
`id`.
Set both thresholds to bracket a band (transfers between $1M and $5M); set one for an open
range. `cooldown_minutes` is what keeps a busy token from filling your inbox, and it is
enforced per rule.
> Alert me on email when more than $5M of USDC moves on Ethereum, at most once an hour.
## `alerts_edit` [#alerts_edit]
**2 credits.** A partial update: only the fields you pass change.
| Parameter | Type | Notes |
| -------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------- |
| `alert_id` | integer | *Required.* From `alerts_list`. |
| `label`, `token_symbol`, `chain`, `min_value_usd`, `max_value_usd`, `channels`, `cooldown_minutes` | — | Same meaning as in `alerts_create` |
| `is_active` | boolean | `false` pauses the alert without deleting it |
The exported schema carries no per-field descriptions for this tool — the fields mirror
`alerts_create` above, which is where their meaning is stated.
> Pause my USDC alert.
> Raise the threshold on alert 41 to $10M.
## `alerts_delete` [#alerts_delete]
**2 credits.** Removes an alert.
| Parameter | Type | Default | Notes |
| ---------- | ------- | ---------- | ---------------------------------------------------------------- |
| `alert_id` | integer | *required* | The alert to delete |
| `hard` | boolean | `false` | `true` removes permanently; the default pauses and keeps history |
**Returns** `{"ok": true}` on success. A soft delete is the same state as `is_active: false`,
so an alert deleted this way still appears under `alerts_list` with `include_inactive: true`
and can be re-enabled with `alerts_edit`.
> Delete alert 41 — actually, just pause it.
## `alerts_register_webhook` [#alerts_register_webhook]
**2 credits.** Registers an HTTPS webhook as a delivery channel. Vike POSTs signed JSON to the
URL when your alerts fire.
| Parameter | Type | Default | Notes |
| --------- | ------ | ---------- | ---------------------------------------------------------- |
| `url` | string | *required* | Webhook URL (http or https), reachable from Vike's servers |
| `secret` | string | — | HMAC secret, 16 characters or more. Recommended. |
### Verifying a delivery [#verifying-a-delivery]
The signing is Stripe-style. Each POST carries:
```
Content-Type: application/json
User-Agent: vike-alerts/1.0
X-Vike-Timestamp:
X-Vike-Signature: t=,sig=
```
Compute `HMAC-SHA256(secret, ".")`, hex-encode it, and compare it
in constant time against the `sig=` value. Reject the delivery if the timestamp is more than
about five minutes old — that is what stops a captured request being replayed.
Without a `secret` the POST still goes out, unsigned. That is fine for a private endpoint and
not fine for anything reachable from the internet, since nothing then distinguishes Vike's POST
from anyone else's.
> Send my alerts to [https://example.com/hooks/vike](https://example.com/hooks/vike) as well, with a signing secret.
## When alerts are evaluated [#when-alerts-are-evaluated]
Alerts are not evaluated inline with the chain. A scheduled checker sweeps recent transfers and
fires matching rules; its lookback window is deliberately twice its run interval, so one missed
run still gets caught rather than dropping the transfers it would have seen. Expect alerting
latency in minutes, and treat these as monitoring rather than as a trading trigger.
# Candles (/docs/ai/data/candles)
Two candle tools, one per venue — deliberately not one tool with a `venue` argument. Vike serves
market data only from venues whose data is on chain, which today means Hyperliquid and Polymarket,
and the two are answered by two different engines. Two tools keep that boundary in the wiring:
crossing it would mean adding a call, not passing a different argument.
Neither tool below takes a contract address, so there are no candles for Ethereum, BSC or Base
tokens, and none for centralised-exchange tickers such as BTCUSDT: vike holds no right to
redistribute centralised-exchange market data. Asked to chart a token, say plainly that it is not
available — never substitute transfer flows, holder counts or any other series for a price series.
## `hl_ohlcv` [#hl_ohlcv]
**1 credit.** Historical OHLCV candles for a Hyperliquid market, aggregated from HL's own executed
fills.
| Parameter | Type | Default | Notes |
| ---------- | ------- | ---------------------- | ----------------------------------------------------------------------------- |
| `symbol` | string | *required* | HL asset: perp ticker (`BTC`), spot token (`HYPE`) or raw spot index (`@107`) |
| `interval` | enum | `1h` | `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d` |
| `market` | enum | `perp` | `perp` or `spot` — perpetual or HL spot |
| `dex` | string | `HL` | Perp DEX. `HL` is the core venue; the others are builder-deployed HIP-3 DEXs |
| `start` | string | `end - limit*interval` | ISO-8601 UTC or epoch ms, inclusive |
| `end` | string | now | ISO-8601 UTC or epoch ms, exclusive |
| `limit` | integer | `500` | Max candles, hard max 5000 |
Buckets are left-aligned UTC and the window is half-open — `[start, end)` — so a bar stamped at the
`end` boundary is not returned. Because `start` defaults to `end - limit*interval`, raising `limit`
on its own reaches further back rather than returning more of the same window.
**Returns** per candle: `ts` (epoch ms, UTC), `open`, `high`, `low`, `close`, `volume`,
`volume_usd` and `n_trades`, ascending by `ts` — plus `source`, naming the rollup that answered.
> Hourly BTC perp candles on Hyperliquid for the last week.
> Daily candles for HYPE on Hyperliquid spot.
## `polymarket_ohlcv` [#polymarket_ohlcv]
**1 credit.** Historical OHLCV bars for **one** Polymarket outcome token, from the recorded L2
tape.
| Parameter | Type | Default | Notes |
| ---------- | ------- | ---------- | ---------------------------------------------------------------------- |
| `token_id` | string | *required* | ERC-1155 outcome token id |
| `interval` | enum | *required* | `1m`, `5m`, `1h`, `1d` — no default, unlike `hl_ohlcv` |
| `from` | integer | — | Epoch **milliseconds**, inclusive lower bound. Omit for no lower bound |
| `to` | integer | — | Epoch **milliseconds**, inclusive upper bound. Omit for no upper bound |
| `limit` | integer | `500` | Max candles, hard max 1000 |
`token_id` is the outcome token, not the market: a `condition_id` will **not** work here. Read the
token ids off `clob_token_ids` from [`polymarket_market_detail`](/docs/ai/data/polymarket), and note
that a Yes/No pair is two ids and therefore two calls. Prices are probabilities in `[0, 1]`, not
dollars.
Two window details differ from `hl_ohlcv` and both bite. The bounds are epoch **milliseconds**
only, and `to` is **inclusive** where `hl_ohlcv`'s `end` is exclusive. And there is no cursor, so a
window wider than `limit` is truncated at the **old** end — to reach the recent end of a long
range, raise `from` rather than paging.
**Returns** bars **oldest first**: `ts` (epoch ms, UTC), `open`, `high`, `low`, `close`, `volume`
and `trades`.
> Chart the Yes side of this Polymarket market over the last month.
> Hourly bars for this outcome token since the start of the week.
# Data MCP server (/docs/ai/data)
`https://vike.io/mcp` is the Vike Data API's [Model Context Protocol](https://modelcontextprotocol.io/)
endpoint: one URL, 31 tools, JSON-RPC 2.0 over HTTP POST. Point an MCP client at it and an agent
can screen tokens, trace a wallet's counterparties, decode its DeFi positions, profile a
Polymarket trader and manage on-chain transfer alerts — conversationally, without you writing a
request.
It is a **remote** server. Vike runs it; your client holds a URL and a credential and nothing
else. There is no binary to install, nothing to keep running on your machine, and no local port.
## Who this is for [#who-this-is-for]
Anyone who would otherwise be writing `curl` against [the REST API](/docs/data/getting-started) inside
a loop of "fetch, look, decide what to fetch next". The tools cover the same data the REST plane
does, but shaped so an agent can chain them: `wallet_summary` hands you an address worth
following, `wallet_counterparties` says who it trades with, `wallet_trace` walks the graph, and
none of that needs you to know an endpoint path.
If you want the data in a program rather than in a conversation, the REST API and the bulk Parquet
archive are the other two planes — [Vike Data API](/docs/data) is the map.
## Connect [#connect]
| Property | Value |
| --------------- | --------------------------------------------------------------------- |
| Endpoint | `https://vike.io/mcp` |
| Transport | Streamable HTTP, `POST` only |
| Protocol | JSON-RPC 2.0 |
| MCP version | `2025-03-26` |
| Server identity | `serverInfo` = `vike-mcp`, version `1.0.0` |
| Methods | `initialize`, `notifications/initialized`, `tools/list`, `tools/call` |
`tools/list` returns the whole catalogue in one response — there is no pagination and no cursor.
Any other method is a `-32601`.
**[Client setup](/docs/ai/data/setup)** has the exact configuration per client — Claude Desktop, Claude
Code, Cursor, Gemini CLI, VS Code — plus [the raw wire protocol](/docs/ai/data/setup/raw-http) for a
script or for debugging a client that will not connect.
## Authentication [#authentication]
Two credentials are accepted, and they are tried in this order:
1. `Authorization: Bearer vk_oat_...` — an OAuth 2.1 access token. Clients that support Dynamic
Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) register
themselves and you approve access in a browser; no key is ever copied. This is the
[Claude Desktop](/docs/ai/data/setup/claude-desktop) path.
2. `X-API-KEY: vk_...` — a long-lived key you create under **Account → API Keys**. Every other
client uses this: Claude Code, Cursor, Gemini CLI, VS Code, curl.
Both resolve to the same user and reach the same 31 tools, so the account-scoped ones (the whole
`alerts_*` family) work identically either way. The difference that shows up later is how the
hourly counter is keyed — see [Rate limits](/docs/data/rate-limits).
[Authentication](/docs/data/authentication) covers both credential types in full.
⚠ A validated credential is cached for **45 seconds**, so a key or token you revoke can keep
authenticating for that window. Revocation is not instant; it is a deliberate trade of a few
seconds of staleness for not re-validating the credential from scratch on every request. Plan a
rotation as *create the new credential, move traffic, then delete the old one* — not the reverse.
## The 31 tools [#the-31-tools]
Six families. Each family page gives every tool its parameters, what it returns, and an example
prompt — shown as a quote — that gets an agent to reach for it. Those pages are generated from the
same tool registry `tools/list` serves, so a live `tools/list` is the tie-breaker if one ever
disagrees.
**`alerts_*` is the only family that writes anything**, and what it writes is your own alert rows
and notification channels. Nothing here touches a market, an order, or a wallet you control — the
server holds no keys and signs no transactions.
## Credits [#credits]
Every call is metered. Each tool has a fixed cost — 0 to 4, and any tool without an explicit cost
counts as 1 — recorded as `credits_used` on the usage row along with the tool name, duration,
status and arguments. Your own calls are visible under **Account → MCP Calls**.
Credits are an accounting figure, not a gate: nothing in the request path compares them against a
balance. What can refuse a call is the hourly limit below, and that counts *calls*, not credits.
| Family | Tools | Credits per call |
| --------------------------------- | :---: | ---------------- |
| [Wallets](/docs/ai/data/wallets) | 11 | 1–4 |
| [Tokens](/docs/ai/data/tokens) | 4 | 0–3 |
| [Polymarket](/docs/ai/data/polymarket) | 4 | 1–2 |
| [Candles](/docs/ai/data/candles) | 2 | 1 |
| [Perps](/docs/ai/data/perps) | 4 | 1–2 |
| [Alerts](/docs/ai/data/alerts) | 6 | 0–3 |
`token_search` is the one 0-credit tool worth knowing by name: it makes a free connection test.
## Rate limits [#rate-limits]
**One hourly counter covers MCP and the REST API; the archive is authenticated against the same
credential but does not spend it.** Every `/mcp` tool call and every `/v1` request is checked
against the same counter, so a `/v1` backfill and an agent's tool calls draw down one shared
budget. One ceiling applies to every credential — there are no plan tiers and no per-endpoint
allowances. [Rate limits](/docs/data/rate-limits) states the number and is the authority for it — this
page does not restate it, because both figures the previous docs printed were wrong.
What the counter is keyed on depends on the credential:
* **API key** — per key. Rows are counted by `api_key_id`, so five keys on one account get five
independent budgets.
* **OAuth token** — pooled per user. OAuth rows carry `api_key_id = 0` and are counted by
`user_id`, so every OAuth-connected app on an account shares one budget.
The count is a backstop rather than a ledger: it is reseeded from the usage log at most once every
30 seconds and incremented in memory between refreshes, so at the boundary it can drift by up to
that window's worth of calls in either direction.
Requests under `/archive/` are exempt from the hourly counter. They are still authenticated with
the same credential and still appear in your usage log, so nothing is lost from the record — the
exemption is only to the budget, and it holds across the counter's periodic refresh, so an archive
download never reappears as consumption a few seconds later. Changed 2026-09-02.
Download whole files anyway; the reason is bandwidth now, not quota. A Parquet reader pointed at an
archive URL fetches the footer and then each column chunk as its own Range request, and throughput
is capped per connection (roughly 50 MB/s). `curl -O` moves the same bytes in one sequential pull
instead of thousands of ranged round trips against that cap, and `curl -C -` resumes an interrupted
one.
The measurement that caused the change, which no longer describes current behaviour: one 1.26 GB
partition produced **20,237 metered requests**, more than an entire hour's ceiling for a single
file, and **572 of the 573** rate-limit rejections in the platform's history were archive
downloads. Anything still describing a ranged archive read as spending your allowance describes the
behaviour before 2026-09-02.
## Two error shapes [#two-error-shapes]
They are different things and they arrive differently, which matters when you are writing the
client rather than talking to it.
**Protocol and credential failures are JSON-RPC error objects.** `-32700` for a body that was not
JSON, `-32601` for an unknown method or an unknown tool name in `tools/call`, `-32000` for an
invalid or missing credential and for an hourly-limit trip. A credential failure also carries
HTTP **401** and a `WWW-Authenticate: Bearer resource_metadata=...` header (where an OAuth client
starts discovery); a rate-limit trip carries HTTP **429**, a `Retry-After` header, and `error.data` holding
`{"code": "RATE_LIMITED", "retry_after": }`.
**A tool failure is not a protocol error.** If a tool raises, the server returns
`{"error": ""}` wrapped as an ordinary *successful* result carrying `isError: true`. The
agent reads the message and retries or asks you; the session is not broken.
Every tool returns its payload the same way regardless: JSON, serialized into a single `text`
content block.
The exact bodies are on [Raw HTTP](/docs/ai/data/setup/raw-http); [Errors](/docs/data/errors) is the
cross-plane reference, including why MCP answers an auth failure with a 200 when the REST plane
answers with a 401.
# Perps (/docs/ai/data/perps)
Four tools over perpetual futures, split by what they can see. `perp_funding` and `perp_spreads`
compare **venues** — a funding rate or a price, per exchange, across every perp venue Vike
ingests. `hl_perp_top_traders` and `hl_position_match` are Hyperliquid-only and work at the level
of a **wallet**.
That asymmetry is the interesting thing about this family, and it is not a gap in the catalogue.
Hyperliquid settles on chain, so every position and fill is public and attributable to an
address — which is why the last two tools can answer *who*: rank traders by realized PnL, or
name the wallet behind a share card. No centralised exchange publishes per-account order flow,
so the two cross-venue tools answer only *where*, and there is no CEX counterpart to
`hl_perp_top_traders` to be built.
`perp_funding` and `perp_spreads` accept `symbol` and nothing else — you cannot restrict the
comparison to a venue, and the schema enumerates none. The exchange arrives in the **response**
instead (`long_exchange` / `short_exchange`, `buy_exchange` / `sell_exchange`). Nor is the
comparison CEX-only: Hyperliquid is one of the venues ranked, its hourly funding normalised to
daily alongside the eight-hourly CEX rates.
## `perp_funding` [#perp_funding]
**1 credit.** Perpetual funding rates, in two modes selected by whether you pass `symbol`.
| Parameter | Type | Default | Notes |
| --------- | ------ | ------- | ------------------------------------------------------------------- |
| `symbol` | string | — | One symbol, e.g. `BTC`. Omit for the cross-venue arbitrage ranking. |
* **With `symbol`** — current funding for that asset on every venue that lists it: the raw
per-interval rate, the funding interval in hours, the next funding timestamp, and 24h perp
notional per venue.
* **Without `symbol`** — pre-ranked cross-venue funding-arbitrage rows, normalised to **daily
percent** and filtered to spreads of at least **0.3%**. Each row names both legs:
`long_exchange` + `long_daily_pct`, `short_exchange` + `short_daily_pct`, and
`spread_daily_pct`.
The normalisation is the point of the second mode. A single-symbol row gives you the rate **per
funding interval, not annualised**, and the interval is not the same on every venue — so
comparing two raw rates directly is a units error. The arb mode does that conversion for you.
Arb mode returns the **top 5** rows and the count is fixed in the handler — there is no `limit`
parameter to raise, and no way to page past the fifth opportunity.
> What is BTC funding right now across venues?
> Where is the best funding arbitrage today?
## `perp_spreads` [#perp_spreads]
**1 credit.** Cross-venue price gaps on perpetual futures — symbols where the cheapest and the
most expensive venue differ by at least **0.5%**.
| Parameter | Type | Default | Notes |
| --------- | ------ | ------- | -------------------------------------------- |
| `symbol` | string | — | Symbol to compare. Omit for the full matrix. |
**Returns** per row: `buy_exchange` + `buy_price`, `sell_exchange` + `sell_price`, and
`spread_pct`. The rows are pre-ranked, so cite them directly rather than re-sorting. Outliers
more than 400 bps from the cross-venue median are already filtered out — those are typically
unit-mismatch listings such as `1000SHIB`, where two venues quote different contract sizes and
the "spread" is an artefact.
One trap worth knowing: **`symbol` filters the ranking, it does not query the symbol.** The
handler computes the top spreads first and then keeps the rows matching `symbol`, so a symbol
that is not already among the widest gaps comes back **empty** — which means "not one of the
biggest spreads right now", never "this symbol has no spread".
> Where are the biggest perp price gaps right now?
## `hl_perp_top_traders` [#hl_perp_top_traders]
**2 credits.** Rank Hyperliquid perp wallets within a time window, by one of six metrics, with
three floors on top. Backed by the same pipeline as vike.io/perps/wallets.
| Parameter | Type | Default | Notes |
| -------------- | ------- | ------- | ------------------------------------------------------------- |
| `window` | enum | `30d` | `1d`, `7d`, `30d`, `all` |
| `sort` | enum | `pnl` | `pnl`, `roi`, `win_rate`, `volume`, `trade_count`, `drawdown` |
| `order` | enum | `desc` | `asc` or `desc` |
| `min_trades` | integer | `3` | Minimum trade count |
| `min_win_rate` | number | `0` | Minimum win rate as percent 0–100; `0` means no filter |
| `min_volume` | number | `0` | Minimum volume USD; `0` means no filter |
| `size` | integer | `10` | Results per page, max 20 |
**Returns** per trader: address, total realized PnL, ROI percent, win rate percent — overall
plus long-side and short-side separately — win, loss and trade counts, total volume, the PnL
split between longs and shorts, max drawdown percent, account value and average leverage,
alongside a `total` count for the filtered population.
Three things about the filters. Win rate is a **percent, not a fraction**: a wallet winning
three trades in four is `75`, so `min_win_rate: 0.75` filters out almost nobody rather than
almost everybody. `min_trades` has a **floor of 3** — a lower value is raised to it, so
single-trade wallets with spectacular ROI cannot be surfaced at all. And `size` caps at 20 with
no page parameter, so the ranking is the first page or nothing; narrow with the floors rather
than paging.
The numeric parameters accept a string as well as a number, so an agent that stringifies its
arguments is not a problem here.
> Top Hyperliquid traders by ROI over the last 7 days with a win rate above 75%.
> Which HL wallets have the largest realized PnL this month?
## `hl_position_match` [#hl_position_match]
**2 credits.** Reverse-lookup: given what a Hyperliquid share card shows — coin, side, leverage
and entry price — return the wallet or wallets holding a matching **open** position, ranked by
price proximity.
| Parameter | Type | Default | Notes |
| ------------- | ------- | ---------- | ------------------------------------------------------------------------------ |
| `coin` | string | *required* | Asset symbol, e.g. `BTC`, `ETH`, `SOL` |
| `side` | enum | *required* | `long` or `short` |
| `leverage` | number | *required* | Leverage from the share card, e.g. `5`, `10`, `25` |
| `entry_price` | number | *required* | Entry price from the share card |
| `mark_price` | number | — | Current mark price; sharpens the tie-break when several wallets share an entry |
| `limit` | integer | `10` | Max matches, max 50 |
**Returns** per match: address, coin, side, snapshot timestamp, position size in tokens and in
USD, entry and mark price, leverage, liquidation price, unrealized PnL, and the entry distance
as a percent. Alongside the matches come `match_count`, the `query` you sent back as an echo,
and `tolerance_used` — the band that actually answered, which is the field that tells you how
confident the identification is.
Matching walks three widening entry-price bands — **tight ±0.05%**, then **loose ±0.5%**, then a
**fallback ±1.0%** — stopping at the first that produces matches, and leverage must land within
**±0.5** in every band. Results are ordered by mark-price distance first and entry distance
second. Omitting `mark_price` is not an error; it falls back to the entry price, which simply
makes the tie-break less discriminating when a popular round-number entry is crowded.
It reads open positions from the latest snapshot, gated to the canonical **15-minute freshness
window**. So an empty result means no *currently open* position matches, not that nobody ever
held one — a card screenshotted yesterday and closed since will find nothing, and that is the
tool working correctly rather than a miss.
> Whose share card is this — 25x ETH long opened at $3,200?
> Who has a 10x BTC short open near $91,500?
# Polymarket (/docs/ai/data/polymarket)
Four tools over Polymarket. The usual path runs left to right: list markets, drill into one,
check where the cohort's money is going, then profile the wallets behind it.
**"Smart money" here has a definition**, used by both `polymarket_smart_money` and the cohort
figures inside `polymarket_market_detail`: wallets with at least $1,000 of realized PnL across
five or more resolved markets. It is a track-record filter, not a label anyone applied by hand.
## `polymarket_markets` [#polymarket_markets]
**1 credit.** Trending Polymarket events ranked by 24-hour volume.
| Parameter | Type | Default | Notes |
| ---------- | ------- | ------- | --------------------------------------------------------------- |
| `category` | string | `all` | `Crypto`, `Sports`, `Politics`, `Pop-Culture`, `Tech`, or `all` |
| `limit` | integer | `10` | Max markets, max 30 |
**Returns** per market: title, slug, category, current Yes/No mid quotes, 24h volume, and
`end_date`.
> What is hot on Polymarket right now?
> Top 20 politics markets by volume today.
## `polymarket_market_detail` [#polymarket_market_detail]
**2 credits.** Everything about one market, keyed by its `condition_id`: the question,
category, outcomes, current quotes, 24h volume, smart-money cohort flows, the top wallets
active in it, and resolution status.
| Parameter | Type | Required | Notes |
| -------------- | ------ | :------: | -------------------------------- |
| `condition_id` | string | yes | Market condition id (hex string) |
The `condition_id` comes from `polymarket_markets`, which is why the two are normally called in
sequence — an agent that has only a market title should list first and match, not guess an id.
> Tell me everything about this market, including who is on each side.
## `polymarket_smart_money` [#polymarket_smart_money]
**2 credits.** Two distinct signals behind one `mode` switch.
| Parameter | Type | Default | Notes |
| ---------- | ------- | ------- | ------------------------ |
| `mode` | enum | `flows` | `flows` or `mispricings` |
| `hours` | integer | `24` | Lookback window in hours |
| `category` | string | `all` | Category filter |
| `limit` | integer | `10` | Max rows, max 20 |
* **`flows`** — markets with the largest net cohort inflow over the lookback. This is "where
the money went".
* **`mispricings`** — markets where the cohort bought meaningfully below the current mid, i.e.
a position that is already in profit on paper. This is "where the money looks early".
Note the window is in **hours**, not a window enum, so a week is `hours: 168`.
> Which Polymarket markets have smart money bought into over the last 6 hours?
> Show me cohort mispricings in crypto markets today.
## `polymarket_wallet` [#polymarket_wallet]
**2 credits.** Profile one Polymarket trader: realized P\&L over 24h / 7d / 30d / all-time,
hit-rate on resolved markets, total volume, top categories traded, and recent positions.
| Parameter | Type | Default | Notes |
| --------- | ------ | ---------- | ------------------------------------------------------- |
| `address` | string | *required* | Polymarket wallet address (0x-prefixed) |
| `window` | enum | `all` | `24h`, `7d`, `30d`, `all` — scopes the windowed metrics |
Hit-rate is computed on **resolved** markets only, so a wallet holding large open positions can
show a thin record until those markets settle.
> Profile this Polymarket wallet — is its record any good?
# Tokens (/docs/ai/data/tokens)
Four tools covering the token side of the on-chain data: find a token, screen the universe by
activity, then look at who holds it and who has been trading it. All four work on Ethereum, BSC
and Base.
There are no per-contract price candles — see [Candles](/docs/ai/data/candles). The four tools below
read our own transfer index.
Token tools other than `token_search` are keyed by **contract address**, not symbol — start
with a search when the user gives you a ticker.
## `token_search` [#token_search]
**Free — 0 credits.** Search tokens by name or symbol.
| Parameter | Type | Default | Notes |
| --------- | ------- | ---------- | ---------------------------- |
| `query` | string | *required* | Search term (symbol or name) |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `limit` | integer | `10` | Max results |
**Returns** matching symbols, names, addresses and logos. Because it costs nothing, it is the
natural first hop for every other tool on this page.
> Find the PEPE token on Ethereum.
## `token_screener` [#token_screener]
**3 credits.** Discover tokens by activity inside a window, ranked by one of four metrics, with
three filters on top. This is the "what is happening today" tool.
| Parameter | Type | Default | Notes |
| ------------------ | ------- | ---------- | ------------------------------------------------- |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `window` | enum | `24h` | `24h`, `7d`, `30d`, `90d` |
| `sort` | enum | `volume` | `volume`, `net_inflow`, `holders`, `smart_inflow` |
| `limit` | integer | `20` | Max tokens, max 100 |
| `min_volume_usd` | number | — | Drop tokens below this 24h volume |
| `min_holders` | integer | — | Drop tokens below this holder count |
| `smart_money_only` | boolean | `false` | Restrict to smart-money cohort flows |
`smart_inflow` and `smart_money_only` are two different knobs: the first *ranks* by cohort
inflow, the second *filters* the universe to cohort flows. Combining them narrows hard.
> What tokens have smart money been accumulating on Base this week?
## `token_holders` [#token_holders]
**2 credits.** Top holders of an ERC-20 token, ranked by cumulative net inflow USD.
| Parameter | Type | Default | Notes |
| --------------- | ------- | ---------- | ------------------------------------- |
| `token_address` | string | *required* | ERC-20 contract address (0x-prefixed) |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `limit` | integer | `10` | Max holders, max 50 |
**Returns** per holder: wallet, net flow USD, volume USD, transaction count, first and last
seen dates, and known labels.
The ranking is by net inflow measured from transfer history, so it answers "who accumulated the
most" rather than "who holds the largest balance on chain right now".
> Show me the top 25 holders of this token, with labels.
## `token_transfers` [#token_transfers]
**2 credits.** Who bought and who sold a token in a period, with quantities and USD values.
| Parameter | Type | Default | Notes |
| --------------- | ------ | ---------- | --------------------------------------- |
| `token_address` | string | *required* | Contract address (0x-prefixed) |
| `period` | enum | `24h` | `1h`, `24h`, `7d`, `30d` |
| `tab` | enum | `sold` | `bought` for buyers, `sold` for sellers |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base`, `all` |
Two things to know. The default `tab` is `sold`, so a question about buyers needs `bought`
passed explicitly. And the result size is fixed at the top 10 in the handler — there is no
`limit` parameter to raise.
> Who has been buying LINK in the last 24 hours?
# Wallets (/docs/ai/data/wallets)
Eleven tools for wallet analytics on Ethereum, BSC and Base. They divide into three jobs:
**describe one wallet** (`wallet_summary`, `wallet_transactions`, `wallet_token_balances`,
`wallet_pnl_history`), **find or connect wallets** (`wallet_discover`, `wallet_counterparties`,
`wallet_trace`, `wallet_compare`), and **name them** (`wallet_ens`, `wallet_batch_labels`,
`wallet_label_provenance`).
Addresses are 0x-prefixed EVM hex throughout. Numeric parameters accept either a number or its
string spelling — an agent that passes `"30"` instead of `30` is coerced, not rejected.
## `wallet_summary` [#wallet_summary]
**2 credits.** A wallet's aggregate metrics: volume, inflow, outflow, net flow, transaction
count and tokens traded, across the 24h / 7d / 30d / 90d / all windows. There is no window
parameter — one call returns every window.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | -------------------------------- |
| `address` | string | yes | Wallet address (0x-prefixed hex) |
> What has 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 been doing lately?
## `wallet_discover` [#wallet_discover]
**2 credits.** Top wallets ranked by a metric you choose — volume, inflow, outflow, net flow,
transaction count, or realized PnL.
| Parameter | Type | Default | Notes |
| --------- | ------- | ---------- | ------------------------------------------------------------ |
| `window` | enum | `30d` | `24h`, `7d`, `30d`, `90d`, `all` |
| `sort` | enum | `volume` | `volume`, `inflow`, `outflow`, `net_flow`, `tx_count`, `pnl` |
| `order` | enum | `desc` | `asc` or `desc` |
| `chain` | enum | `ethereum` | `ethereum` or `bsc` |
| `size` | integer | `10` | Results per page, capped at 20 |
**Returns** `{"wallets": [...], "total": n}`. Each row carries `address`, `volume`, `inflow`,
`outflow`, `net_flow`, `tx_count`, `tokens_traded`, `first_seen`, `last_active`,
`realized_pnl`, `tokens_profitable` and `pnl_available` — the USD columns already converted to
whole dollars.
> Top 20 BSC wallets by realized PnL over the last 7 days.
## `wallet_transactions` [#wallet_transactions]
**2 credits.** The raw per-wallet transfer list — single transactions, not aggregates. This is
the forensic tool: use it when the question is "show me the actual transfers", not "how much
did this wallet do".
| Parameter | Type | Default | Notes |
| --------------- | ------- | ---------- | ------------------------------ |
| `address` | string | *required* | Wallet address (0x-prefixed) |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `direction` | enum | `all` | `in`, `out`, `all` |
| `days` | integer | `30` | Days back, max 365 |
| `limit` | integer | `50` | Max transactions, max 500 |
| `min_usd` | number | `0` | Drop dust below this USD value |
| `token_address` | string | — | Restrict to one token |
**Returns** per transfer: `time`, `tx_hash`, `from`, `to`, `token_address`, `value_usd`,
`direction`, `block`.
> List the last 100 outgoing transfers over $10k from 0xabc… on Base.
## `wallet_token_balances` [#wallet_token_balances]
**3 credits.** Current ERC-20 balances for a wallet across major EVM chains, sourced from
Moralis. Dust below $1 is filtered by default.
| Parameter | Type | Default | Notes |
| --------- | ------ | --------------------------------------------- | ---------------------------------- |
| `address` | string | *required* | Wallet address (0x-prefixed) |
| `chains` | array | `eth, bsc, base, arbitrum, polygon, optimism` | Chains to scan |
| `min_usd` | number | `1.0` | Drop holdings below this USD value |
**Returns** one row per holding: chain, symbol, balance, USD value, USD price.
Pair it with `wallet_label_provenance` for institutional context.
> What tokens does this wallet hold right now, across all chains?
## `wallet_pnl_history` [#wallet_pnl_history]
**2 credits.** A daily net-flow series for a wallet — per-day net inflow USD, plus cumulative
net flow, gross volume and transaction count.
| Parameter | Type | Default | Notes |
| --------- | ------- | ---------- | ---------------------------- |
| `address` | string | *required* | Wallet address (0x-prefixed) |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `days` | integer | `30` | Days back, max 365 |
The tool's own description says so: true realized PnL would need weighted-average-cost
accounting per token, which this series does not do. Read it as a directional indicator of
whether a wallet has been accumulating or distributing, not as a profit figure.
> Plot the daily net flow of 0xabc… over the last 90 days.
## `wallet_counterparties` [#wallet_counterparties]
**2 credits.** Top trading partners of a wallet by combined transfer volume.
| Parameter | Type | Default | Notes |
| --------- | ------- | ---------- | ---------------------------------------- |
| `address` | string | *required* | Wallet address (0x-prefixed) |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `window` | enum | `30d` | `24h`, `7d`, `30d`, `90d`, `180d`, `all` |
| `limit` | integer | `10` | Max counterparties, max 50 |
**Returns** per counterparty: address, total volume, amount sent and received, transaction
count, first and last seen, and labels where known.
> Who does this wallet trade with most?
## `wallet_trace` [#wallet_trace]
**4 credits — the most expensive tool in the catalogue.** Follows a wallet's money outward:
at each hop it expands to the top N counterparties by USD volume and repeats, returning the
nodes and edges of the resulting graph.
| Parameter | Type | Default | Notes |
| --------- | ------- | ---------- | ---------------------------------- |
| `address` | string | *required* | Root wallet to trace from |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `depth` | integer | `2` | Hops to follow, 1–5 |
| `width` | integer | `5` | Top N counterparties per hop, 1–10 |
| `window` | enum | `30d` | `24h`, `7d`, `30d`, `90d`, `all` |
| `min_usd` | number | `10000` | Minimum USD per transfer to follow |
Cost grows with `depth` × `width`, and so does noise. Lowering `min_usd` on a busy wallet
widens the graph fast; the $10k floor exists to keep a trace legible.
> Follow the money three hops out from 0xabc…, top 5 counterparties per hop.
## `wallet_compare` [#wallet_compare]
**3 credits.** Asks whether two wallets are related, by intersecting what they touched: shared
counterparties (addresses both wallets transacted with) and shared tokens (tokens both wallets
traded) inside one window.
| Parameter | Type | Default | Notes |
| ----------- | ------- | ---------- | ---------------------------------------- |
| `address_a` | string | *required* | First wallet |
| `address_b` | string | *required* | Second wallet |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
| `window` | enum | `30d` | `24h`, `7d`, `30d`, `90d`, `180d`, `all` |
| `limit` | integer | `10` | Max shared items per category, max 50 |
An overlap is evidence, not proof — two wallets that both used a large exchange share that
counterparty with everyone else.
> Are 0xaaa… and 0xbbb… related? Compare them over 90 days.
## `wallet_ens` [#wallet_ens]
**1 credit.** Reverse ENS lookup: an address in, a name like `vitalik.eth` out, or `null` when
the address has no ENS name set.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | ----------------------------------- |
| `address` | string | yes | EVM address (0x-prefixed, 42 chars) |
> What is the ENS name for 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?
## `wallet_batch_labels` [#wallet_batch_labels]
**2 credits.** Bulk enrichment: labels plus a 30-day summary for many addresses in one call.
Rows come back in input order, so a pasted CSV keeps its shape.
| Parameter | Type | Default | Notes |
| ----------- | ----- | ---------- | --------------------------------------- |
| `addresses` | array | *required* | Wallet addresses (0x-prefixed), max 100 |
| `chain` | enum | `ethereum` | `ethereum`, `bsc`, `base` |
**Returns** one row per input address: `entity`, `wallet_type`, `risk_flag`, 30-day net flow,
30-day volume, 30-day transaction count.
> Here are 60 addresses — label them and tell me which ones moved money this month.
## `wallet_label_provenance` [#wallet_label_provenance]
**1 credit.** The full label cascade for one Ethereum wallet, with per-source attribution:
Arkham, OLI, Blockscout, Dune, the MEW darklist, Etherscan, and Vike's heuristic classifier,
plus a consensus entity and type.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | -------------------------------- |
| `address` | string | yes | Wallet address (0x-prefixed hex) |
Use this when the answer matters enough that *who says so* matters: a single consensus label
hides whether seven sources agreed or one source guessed.
> Who labeled this address, and do the sources agree?
# Data API changelog (/docs/changelog/data)
This page records changes a **caller** can see: `/v1` REST routes added and removed, MCP tools
added and fixed, response fields that appeared or changed meaning, error and rate-limit behaviour,
and the auth and metering rules on the bulk Parquet archive. Newest first.
It does not cover the Vike trading platform — that is the [Trader changelog](/docs/changelog/trader) —
nor the website's own `/api/*` and HTML routes, nor internal plumbing no key-holder can call.
**The API is versioned by path prefix.** `/v1` is the only version that has ever been published,
and, as the marked entries below record, routes have been removed and parameters have started
returning `400` *inside* that prefix rather than behind a new one. Do not assume `/v1` is frozen —
scan the markers.
An entry tagged **⚠ BREAKING** removed an endpoint, made a parameter start returning `400`, changed
the shape of a response, or added a credential requirement where there was none. Everything else is
additive, or a fix to an answer that was wrong.
Entries are written from the change that actually landed rather than from probing the deployed
API, so a fix is dated when it shipped, not when someone noticed it.
## 2026-10 [#2026-10]
* **2026-10-16 · `GET /v1/polymarket/wallets/{address}` and `GET /v1/polymarket/smart_money` added** —
the two Polymarket trader tools that existed only over MCP (`polymarket_wallet`,
`polymarket_smart_money`) now have REST routes that run the same code, so the two surfaces return
the same payload. They read the on-chain trade tape, not the recorded book. The polymarket
namespace is now 11 routes (68 across the API). Additive; nothing changed on the nine existing
routes.
* **2026-10-16 · ⚠ BREAKING · MCP `options_flow`, `web_search` and `web_fetch` are removed** —
`tools/list` now returns 31 tools. `options_flow` (Deribit call/put flow) had 1 call in the
previous 90 days; the two web tools are a proxy to a third-party search service rather than Vike
data, and `web_fetch` fetched arbitrary URLs from our servers. A `tools/call` for any of the
three now returns the unknown-tool error. Nothing replaces them.
* **2026-10-16 · ⚠ BREAKING · `fund_holdings` and `GET /v1/onchain/funds/holdings` are removed** —
the labelled fund / market-maker / treasury / CEX leaderboard is no longer offered on any
surface. The MCP tool is gone from `tools/list` (34 tools remain), the route answers `404`, and
the `/data` dataset page and its panel are gone with it. Callers of the REST route or the tool
have to drop the call; nothing replaces it.
## 2026-09 [#2026-09]
* **2026-09-21 · ⚠ BREAKING · MCP `token_chart` removed; `ohlcv` replaced by `hl_ohlcv` and
`polymarket_ohlcv`** — both tools read the merged centralised-exchange candle engine. `ohlcv`
read it directly; `token_chart` resolved an ERC-20 contract to a canonical symbol and read the
same engine behind it, which is why a per-contract chart could answer for a token that trades
nowhere on chain. Vike redistributes market data only from venues whose data is itself on
chain — Hyperliquid and Polymarket — so the candle tools are scoped to those two rather than
filtered venue by venue.
`tools/call` with either retired name returns `-32601`. `hl_ohlcv` serves Hyperliquid candles
and `polymarket_ohlcv` serves Polymarket candles; there is no replacement for a candle on a
centralised venue, and no candle tool takes a contract address. The REST candle routes are
unaffected by this entry — `/v1/hyperliquid/ohlcv(/export)` and `/v1/polymarket/ohlcv` already
serve the same two venues.
* **2026-09-04 · ⚠ BREAKING · `/v1/onchain/wallets/{address}/balances` reads the chain, and covers
two chains instead of six** — balances are now `balanceOf` calls against our own Ethereum and BSC
nodes, with the candidate token list drawn from our transfer index. `base`, `arbitrum`, `polygon`
and `optimism` are **no longer scanned**: they come back named in `chain_errors` rather than
silently contributing nothing.
Two response fields are new — `balance_raw` (the contract's integer, as a string, for callers
that need exactness) and `name`. `usd_value` and `usd_price` are now `null` on a holding we
cannot price, and the holding is still listed; `total_usd` sums only the priced ones and should
be read as a floor. The previous provider filtered unpriced holdings out entirely.
The route no longer answers `200` with an empty `holdings` array when the lookup fails. That was
the old shape and it read exactly like an empty wallet.
* **2026-09-04 · ⚠ BREAKING · `GET /v1/onchain/wallets/{address}/defi` is removed** — decoded
DeFi protocol positions, and the `defi_positions` MCP tool beside it. The route was served by an
upstream vendor whose free tier was withdrawn on 2026-09-03, and it cannot be rebuilt from our
own data: we record `Transfer` events, and a lending position is not a transfer. It recorded
**4 calls in its entire history**, so it was retired rather than replaced. `fund_holdings` is
unaffected. Calls now return `404`.
* **2026-09-04 · `/v1/onchain/wallets/{address}/ens` resolves against our own node** — the
fallback for an address our pre-resolved table has not seen was an external API; it is now two
`eth_call`s to the ENS registry over our own Ethereum RPC. No behaviour change for a caller
beyond the obvious one: the fallback works again, where since 2026-09-03 it returned nothing.
The name is forward-verified before it is returned — a reverse record is written by the address
itself and means nothing until resolving it forward lands back on the same address — so a
spoofed record answers `null` rather than somebody else's name.
* **2026-09-03 · ⚠ BREAKING · the flat Polymarket paths were retired** — `/v1/markets`,
`/v1/markets/{condition_id}`, `/v1/market_stats`, `/v1/ohlcv`, `/v1/orderbook`,
`/v1/settlements/{condition_id}` and `/v1/events` now return `404`. They predated the
`/v1/polymarket/` namespace and answered alongside it for compatibility. Add `/polymarket`
after `/v1` and nothing else changes — same handlers, same responses. Retired because the usage
record showed no caller: 28 requests across their entire history, all from the account that
owns the platform.
* **2026-09-03 · Polymarket moved to the same service as every other namespace** — the nine
endpoints were served by a separate process and are now served by the same application as
`onchain` and `hyperliquid`. No path, parameter or response shape changed. What did change is
below.
* **2026-09-03 · ⚠ BREAKING · market state is real** — `active` and `closed` were CONSTANTS: every
market reported `active=1, closed=0`, so `?active=0` returned nothing at all and `?closed=1`
returned open markets with a `200`. State is now derived from on-chain resolutions and the
venue's own flag, either of which alone misses thousands. **119,047 of 121,275 markets report
closed where none did before.** ⚠ If you filtered on these fields, your results change — that is
the fix, not a regression. `closed` is also accepted now as the complement of `active`; passing
both is refused rather than silently resolved.
* **2026-09-02 · Archive downloads no longer spend the hourly request budget** — a ranged `GET`
against `/archive/` is still authenticated and still writes its usage row, but it no longer
consumes the hourly request allowance. Bulk Parquet readers (pyarrow, DuckDB, fsspec) fetch a
footer and then one ranged `GET` per column chunk, each of which cost a unit: one 1.26 GB
partition measured **20,237 usage rows**, more than the whole 20,000/hour ceiling for a single
file, and almost every rate-limit rejection recorded in the platform's whole history was an
archive download. `/v1` and `/mcp` still meter normally, and the counter's periodic reseed excludes
`archive_download` rows too, so the skip is not erased 30 seconds later.
* **⚠ BREAKING · 2026-09-02 · Rate-limit rejections speak one dialect on every surface** — a
throttled request now returns `429` with `Retry-After: 60` and
`{"error": {"code": "RATE_LIMITED", ...}}`. Previously `/v1` raised `{"detail": ...}`, the archive
gate hand-built `{"error": "rate limited"}`, and `/mcp` answered `200` with a JSON-RPC error — so
a client could not detect throttling by status code at all. `/mcp` still returns a valid JSON-RPC
body and carries the envelope in `error.data`.
* **2026-09-02 · The published OpenAPI spec declares `X-API-KEY`** — the `/v1` spec carried no
`securitySchemes` and no `security`, so a client generated from it omitted the API-key header and
`401`'d on every call. The schemes are injected into the generated spec itself, so the live spec
and the exported file cannot disagree; ReDoc moved under `/v1/` at the same time.
* **⚠ BREAKING · 2026-09-02 · The website app stopped publishing its route table** —
`/openapi.json` (283 paths), `/docs` and `/redoc` are switched off on the website app. Only the
data API publishes a schema, scoped under `/v1/`. No `/v1` route changed.
* **⚠ BREAKING · 2026-09-02 · `/v1/etf/*` and `/v1/marketdata/ohlcv(/export)` removed** — the ETF
router is no longer mounted and the marketdata candle routes are deleted; both now `404` for an
authenticated caller, a closure pinned by a regression test so it cannot quietly come back.
Public candles remain at `/v1/hyperliquid/ohlcv(/export)`. Usage showed exactly one key had ever
called each surface, last on Aug 17 and Aug 6.
* **2026-09-02 · New docs site at vike.io/docs/data** — replaces the MkDocs site, which documented
8 of the 36 MCP tools the server exposes and quoted rate limits wrong by more than an order of
magnitude. It covers getting started, authentication, rate limits, errors, the full 36-tool MCP
reference generated from what the registry's `tools/list` actually serves, per-client setup
(Claude Desktop and Code, Cursor, Gemini CLI, VS Code, raw HTTP), the `/v1` REST overview and the
archive; the [Polymarket REST reference](/docs/data/reference/polymarket) landed the same day.
`PRs #852 · 0b032622`, `#855 · a0afdb91`, `#856 · ecf9a730`
* **2026-09-01 · Non-finite floats serialize as `null` instead of failing the request** — a single
`NaN` or `Infinity` anywhere in a payload previously produced a `503` through the error handler
or a `500` on `/v1`, losing the entire response; the field is now `null` and the rest of the body
arrives. It is also a large latency win on bulk pages: a 5,000-candle page went
**44.14 ms to 1.41 ms**, and NDJSON export lines **3.35 µs to 0.30 µs**.
## 2026-08 [#2026-08]
* **2026-08-22 · MCP `hl_position_match` stopped returning closed positions** — the tool read a
latest-known-position view with no recency filter, and that view keeps a wallet's last-seen row
forever (**89.8% of rows more than a day stale**, the oldest from 2026-04-02), so
"who has a 25x ETH long open at $3,200" could answer with a position closed in April while the
tool card promised `<5 min` staleness. Results are now gated on the newest snapshot batch for the
asset.
* **⚠ BREAKING · 2026-08-19 · `/v1/hyperliquid/coins/{coin}/metrics` refuses `labelBasis` and
declares its contamination** — the route filters every historical hour by *today's* wallet
grading, resolved at request time rather than as of the hour being reported. Measured on the
2026-04-20T12:00Z BTC "3x smart" cohort: **938,495,920.6 against a point-in-time
861,706,562.3 — 8.91% inflated.** It
used to answer `200` to a request for honest labels; it now returns `400` for the parameter and
points at `/cohort-metrics`.
* **2026-08-19 · `grading=` on `/v1/hyperliquid/coins/{coin}/cohort-metrics`, and responses declare
when the label was computed** — one parameter selects among `realized`, `realized-pit` and
`unrealized`, which previously took three different spellings (`axis=pnl` plus `labelBasis`, a
separate axis, and no parameter at all). Every response now carries `label_computed` on the
envelope and `grading` on every row.
* **2026-08-19 · `freshness` object added to cohort-metrics responses** — windows reaching the
present now carry `freshness: {newest_ts, age_seconds, settled_lag_ceiling_seconds, stale}`.
Before this, `?grading=realized-pit&hours=1` with no `start` returned its own newest bucket —
measured **63 hours old** and identical in shape to a live one, with nothing in the payload
letting a program tell them apart. `stale` is the precomputed verdict, so a caller need not
re-derive the rollup cadence. (figure corrected in)
* **2026-08-18 · New endpoint: `GET /v1/hyperliquid/ohlcv` (and `/export`)** — minute-resolution
Hyperliquid candles built from executed fills, mirroring `/v1/marketdata/ohlcv`'s parameters and
envelope. Two parameters are Hyperliquid-specific: `dex` (HL core versus builder-deployed HIP-3
perp DEXs) and tick intervals such as `15t`.
* **2026-08-17 · New cohort axis: `axis=pnl_unrealized`** — grades a wallet by unrealized PnL on
open positions as a share of account value, a different signal from the realized ladder:
**44% of wallets change unrealized rung day over day, against 2.4% on realized.** Point-in-time by
construction, and not reachable through the API at all before this.
* **⚠ BREAKING · 2026-08-16 · `/v1/marketdata/orderbook` removed** — the L1/L2 recorder behind it
was retired and its stored datasets dropped (quotes 1.376B rows, book events 531M, trades 296M,
among others; \~51 GiB reclaimed). The route was their only consumer, so it goes with them rather
than becoming a permanent `503`. There is no orderbook surface.
* **2026-08-15 · New namespace: `/v1/etf/*` (physical-ETF holdings)** — 23 funds, 19 of them with a
daily measured source; `quantity` is a measured count in the fund's own unit, never assets divided
by a price. ⚠ This namespace was closed again on 2026-09-02.
* **2026-08-12 · Repeated identical `/v1` requests return byte-identical bodies** — the query
engine reduces `sum()` in an unpinned parallel order and f64 addition is not associative, so the
last digits of a nine-figure notional were reduction noise: **one URL executed six times
returned six distinct bodies, with 24–35% of values differing pairwise.** Values are now snapped
to 10 significant digits at the serialization boundary with a midpoint nudge, which took one research
pipeline's content-addressed cache from broken to working; consumers no longer need to
canonicalize floats themselves.
* **⚠ BREAKING · 2026-08-11 · One canonical position-size ladder; retired labels now `400`; new
`GET /v1/hyperliquid/cohorts`** — the position-size axis had four vocabularies (the site's
dollar-range ladder, a stored `micro`..`mega` tier vocabulary, and two reconstruction
vocabularies). The dollar-range ladder wins: `axis=tier`, `/coins/{coin}/size-breakdown` and
`/segments` `trade_size` all derive the bucket from `size_usd` and serve the same canonical ten
labels, largest first, and a retired label in a cohort filter returns `400` naming the canon. The
new flat taxonomy endpoint `GET /v1/hyperliquid/cohorts` publishes all three ladders.
* **2026-08-11 · New cohort axis: `axis=tier` on `/coins/{coin}/cohort-metrics`** — the per-position
size ladder as the third matrix axis. It is rejected on the per-wallet aggregates (heatmap,
segment summary) because a wallet straddles tiers, and the refusal names where the axis *is*
valid; a conflicting `labelBasis` is a `400` rather than a silent downgrade.
* **2026-08-06 · `labelBasis=point_in_time` on `axis=pnl` — the pnl axis had lookahead** —
`axis=pnl` resolved the cohort at query time, so a historical row came back with today's grading
and a backtest was told which wallets eventually became rankable. Measured on BTC over 3 days:
**0 of 852 cells exact before, 852 of 852 after** (max relative error 5.45e-16), and hourly totals
from 4.37% mean error to 0.000000%. The frozen source costs five fields — no position count, no
unrealized PnL, no token-denominated size — which come back `null` rather than `0` and are named
in `unavailable_fields`; every row and the envelope carry `label_basis`.
* **2026-08-06 · New endpoint: `GET /v1/hyperliquid/coins/{coin}/cohort-metrics`** — returns the
full coin-by-cohort matrix in one request instead of one request per bucket (12 for size, 13 for
pnl). Measured on BTC over 24 hours: **0.25–0.44 s per axis against \~12 s for the equivalent
loop.** Paginated by hour rather than by row, so a page never lands mid-bucket.
* **2026-08-06 · `provisional` flag on `/coins/{coin}/metrics` rows** — the endpoint reads an hourly
rollup that does not write hour H until H+1:00 to H+1:30; measured, **the newest row was 114
minutes old** and a client polling for "current positioning" had no way to know. Rows now carry
`provisional`, true on at most one row (the unsettled right edge), and the live row cuts staleness
to roughly 5 minutes.
* **2026-08-06 · `/segments` rows carry `groupable`; axis error messages fixed** — `/segments`
published four axes but only three were valid as `?axis=` (`trade_size` buckets a position, not a
wallet), with nothing in the payload saying so; every row now carries `groupable` so a generated
client can filter. Refusals name where the axis is valid, and the "forgot the axis" case no longer
reports the server's own default back at the caller.
* **2026-08-04 · The `/v1` data API goes live on data.vike.io** — 34 routes across four namespaces
under one host: `marketdata`, `onchain`, `hyperliquid` and `polymarket`. The error contract from
day one: `400` for caller input, `503` for upstream, never a `200` with an empty body. The same
work made archive authentication independent of the website, so a website deploy can no longer
`500` an archive download — the mechanism of the 2026-07-31 outage.
`PRs #574 · af98d88d`, `#584 · 638a6d93`
* **2026-08-04 · `/v1/hyperliquid` expands from 2 endpoints to 22** — new: cohort catalogue,
per-cohort summary and coin-by-cohort heatmap; position list and history, market-wide OI, hourly
coin metrics, trade-size breakdown; trader leaderboard with the full risk-metric set, and
per-wallet summary, trades, trade-stats, fills, funding and ledger; liquidation fills, a
price-binned liquidation heatmap and liquidation risk by asset; ecosystem stats, perp volume and
HYPE holders. Pagination is keyset throughout.
* **2026-08-04 · New: `/v1/hyperliquid/builders/*` (6 routes)** — leaderboard, builder profile, that
builder's traders, raw fills, a per-bucket time series, and the reverse lookup of which builders a
wallet routes through. Schema note for consumers: `builder_fee` and `deployer_fee` are USD, but
`fee` is denominated in `fee_token` (**1.04% of rows pay in something else**), so fee aggregates
are USD-only and the profile reports what it excluded.
* **2026-08-04 · New: `GET /v1/hyperliquid/builders/stats`, plus richer builder fields** —
leaderboard rows gain `revenue_per_user`, `taker_pct`, `market_share_pct` and
`first_seen_lifetime`; the profile gains `profitable_users(_pct)`, `biggest_day`,
`market_share_pct`, a lifetime block and `user_volume_distribution` (volume deciles).
`/builders/stats` returns ecosystem totals, all-time builder revenue and a daily series. The same
change fixes a keyset pagination bug that could drop or repeat rows.
* **2026-08-04 · `axis=pnl_abs`, a position-age filter, and `/leaderboard/flow-adjusted`
re-enabled** — the board was withheld in #588 and now ranks on **total realized PnL**
(closed round-trips), because the upstream ledger records sends one-sided: over 30 days,
**1,010,263 negative against 835,295 positive send rows, netting -$1.27B**, so an outflow was
recorded while the matching inflow was invisible and booked as profit. The attributed figures ship
under their own `attributed` key with a `flow_unbalanced` flag.
* **2026-08-04 · `/v1/marketdata/options/chain` returned settled contracts, not the live chain** —
the query had no time bound: it aggregated the whole recorded history and returned the oldest
expiries first, so a BTC chain request came back with contracts that had **settled eight weeks
earlier** while the live chain was three expiries inside the next 72 hours.
The read is now bounded to one snapshot.
## 2026-07 [#2026-07]
* **2026-07-29 · Hourly rate limit raised from 2,000 to 20,000 requests per key** — the 2,000
ceiling was sized for the MCP and AI-chat surface and inherited by the data routes, where it was
far too low: **a real hour measured 2,543 calls with 558 rejected.** It is one shared ceiling
across `/v1`, `/mcp` and the archive, not a per-surface number — see
[Rate limits](/docs/data/rate-limits).
* **⚠ BREAKING · 2026-07-29 · The Parquet archive became credential-gated and metered, and usage is
labeled per surface** — `/archive/` downloads are now validated at the edge through the same
API-key and OAuth auth, the same hourly limit and the same metering as `/mcp`, so an
unauthenticated download stops working. Usage rows are labeled from the request URI rather than
hardcoded — `/archive/...` to `archive_download`, `/v1/...` to `api_`, `/mcp` to
`mcp`, anything else to `api_other`. Auth and rate-limit *decision* logic elsewhere was unchanged;
only the label.
## 2026-06 [#2026-06]
* **2026-06-16 · MCP `perp_funding(symbol=...)` fixed — it errored on every call** — any
symbol-scoped request returned an error instead of data; only the unscoped call worked. The
symbol path was rewritten.
## 2026-05 [#2026-05]
* **2026-05-30 · New: a historical OHLCV API (REST + MCP)** — API-key-gated historical candles
built on a canonical 1-minute base and correct at any timeframe from `1m` to `1w`: UTC
left-aligned buckets, partial buckets excluded so the series is look-ahead-safe, and
deterministic cursor pagination. Shares MCP auth, the hourly rate limit and usage logging.
⚠ This surface is no longer documented — see the note at the foot of this page — and its MCP
tool was removed on 2026-09-21.
* **2026-05-30 · `include_partial` flag on `ohlcv` (REST + MCP); `token_chart` returns real
candles** — `include_partial` is off by default (look-ahead-safe); switched on, it includes the
in-progress current candle. `token_chart` was a stub and now resolves a token address to a
canonical symbol and serves real candles through the ohlcv engine, with the legacy `5`/`30`/`120`
values mapping to `5m`/`30m`/`2h`. ⚠ Both tools were removed on 2026-09-21.
* **2026-05-26 · New MCP tool: `hl_position_match`** — reverse-lookup of Hyperliquid share cards.
`72bafc07`
* **2026-05-25 · Hourly per-key cap raised from 100 to 2,000** — headroom for agent workloads, where
one skill workflow can call 5–15 tools per turn. `da4c744d`
* **2026-05-25 · 5 new MCP tools (v0.7)** — `wallet_transactions`, `wallet_compare`, `wallet_trace`,
`wallet_batch_labels`, `token_screener`. `fdc32aad`
* **2026-05-25 · 4 new MCP tools (v0.5.0)** — `alerts_register_webhook`, `wallet_counterparties`,
`wallet_pnl_history`, `wallet_token_balances`. `2832bb04`
* **2026-05-25 · New MCP tool `wallet_ens`; `defi_positions` repointed from DeBank to Moralis** —
the provider swap changes the data source behind an existing tool. `00cfc5ce`
* **2026-05-25 · 2 new MCP tools** — `web_search`, `web_fetch`. `e2143e3f`
* **2026-05-25 · 4 new MCP tools (v0.3.0)** — `fund_holdings`, `wallet_label_provenance`,
`token_holders`, `defi_positions`. `65a51900`
* **2026-05-25 · 4 new Polymarket MCP tools** — `polymarket_markets`, `polymarket_market_detail`,
`polymarket_wallet`, `polymarket_smart_money`. `57d327c1`
## 2026-04 [#2026-04]
* **2026-04-20 · New MCP tool: `hl_perp_top_traders`** — `138e9234`
* **2026-04-19 · OAuth 2.1 and Dynamic Client Registration on `/mcp`** — the MCP endpoint accepts
`Authorization: Bearer` alongside `X-API-KEY`, with RFC 8414 discovery, RFC 7591 dynamic client
registration, Authorization Code + PKCE S256, refresh-token rotation and RFC 7009 revocation — so
Claude Desktop's "Add custom connector" dialog works with no `mcp-remote` and no JSON config. The
rate limit stays per credential: per `api_key_id` for API keys, pooled per `user_id` for OAuth.
`72774187`
* **2026-04-16 · MCP server launches: `POST /mcp`, JSON-RPC 2.0, `X-API-KEY` auth, 9 tools** —
initial tools: token search, wallet summary, wallet discover, transfers, chart, perps leaderboard,
funding, spreads, options flow. Per-tool credit costs, hourly rate limiting and usage tracking
ship with it. `7d9bec99`
## What this page cannot see [#what-this-page-cannot-see]
Entries are derived from the repository's own history — read from the commit diff, not the subject
line — rather than from probing the deployed API. Four gaps are structural, and it is better to name
them than to let the page imply completeness:
* **`/v1/polymarket/*` has no dated history here.** Those routes are served by a separate system
with its own history, and were frozen at the `/v1` cutover on 2026-08-04. The current surface is
documented in [Polymarket REST](/docs/data/reference/polymarket); its change history is not on this
page.
* **Some surfaces this history records are no longer documented.** Entries above describe
endpoints and MCP tools that shipped and are not in the current reference — the CEX candle,
perp and options surfaces. ⚠ They carry no **⚠ BREAKING** entry deliberately, because that
marker means a caller had to change something and nothing here was withdrawn from callers: the
documentation was withdrawn, not the routes. Treating an undocumented surface as a supported
one is the mistake this note exists to prevent.
* **Edge configuration is applied outside this history.** Routing, edge caching, throughput caps,
compression and which paths are gated can change without a commit to record, so an edge-only
change is invisible here.
* **The archive's dataset inventory is generated on the host, not committed.** There are no
"dataset added to the archive" entries above, and that is absence of evidence rather than evidence
of absence — the open `manifest.json` is the live answer for what the archive holds today.
# Authoring (/docs/ai/trader/authoring)
Four tools for writing a strategy, and none of them touches a server: they run entirely inside
the `vike-cli` process, so they work with no `--addr`, no `--node` and no keys. Two compile a
script (`validate_strategy`, `discover_params`), two answer "what may I write" (`list_templates`,
`list_indicators`).
Strategies are [Rhai](https://rhai.rs/) scripts. Tunable knobs are declared with
`param(name, default)` at the top level; the trading logic lives in `fn on_bar()`.
## `validate_strategy` [#validate_strategy]
Compile a Rhai strategy — compile *is* validation. The check runs through
`crates/vike-script/src/strategy.rs`'s `discover_params`, which compiles the script and runs its
top level exactly once, with no broker attached. That is the same error surface you get from
mounting the strategy for real: the daemon mounts through `RhaiStrategy::compile_with_params`
(`resolve_script` in `crates/vike-tradehub/src/config.rs` is the call site), and that performs the
same one-time top-level run.
`discover_params` is a **public** entry point, so the same broker-free check is callable from your
own Rust code and not only through this tool.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | -------------------- |
| `script` | string | yes | Rhai strategy source |
**Returns** `ok: true`, or `ok: false` with the compiler's `error` string. A bad script is an
*answer*, not a tool failure — the agent reads the error and fixes the script. Only a missing
`script` argument is an `isError` result.
> Does this strategy compile?
## `discover_params` [#discover_params]
Read back the tunable `param(name, default)` knobs a script declares — the exact keys a profile's
`[strategy.params]` or a `[sweep]` table can override, in declaration order.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | -------------------- |
| `script` | string | yes | Rhai strategy source |
**Returns** `params`, one row per knob: `name` and its `default`.
Note the deliberate asymmetry with the tool above: both call the same compile path, but a compile
error here *is* a tool error (`rhai compile error: …`), because there are no parameters to report.
Call it on a script you just wrote, not only on one you were handed. `validate_strategy` answers
`ok: true` and says nothing about params, and this tool runs the top level only — so a `param()`
reached from inside `on_bar()` is reported by neither, which makes it exactly as invisible to the
author as to a reader. The tool's own description says so now (`tools_spec` in
`crates/vike-cli/src/cmd/mcp.rs`), because an agent that skipped this step and answered from its
own authorship is what the change was measured against.
> What knobs does this strategy expose, and what are their defaults?
## `list_templates` [#list_templates]
The starter strategies, as `{name, code}` rows: **SMA cross**, **RSI reversion** and **Donchian
breakout**. Each is parameterized via `param()`, so a template drops straight into `run_sweep`
without editing. The sources are static data in `crates/vike-cli/src/cmd/mcp.rs`'s `TEMPLATES`,
copied verbatim from `vike_studio_core::templates`.
That is a hand copy, not a shared import, and there is **no cross-crate drift gate** — the two
copies must be edited together. They are identical today, and nothing but that convention keeps
them so; each side carries its own execution gate over its own copy instead.
This tool takes no arguments.
Two tests stand behind the list, and the second is the one that matters: one *compiles* every
template, and `every_shipped_template_reaches_the_broker` *runs* each one over 80 bars against a
broker double and asserts an order actually arrives. Compiling is the wrong bar for a surface an
agent copies from — a strategy whose every call sits inside `fn on_bar()` compiles perfectly and
can still fail on every single bar (see the callout below).
It stands in with `sma`, symmetrically, because a bare `donchian(len)` is not bound. `bare_name_binds`
in `crates/vike-script/src/engine.rs` binds a bare name when the indicator has one output or when its
first line is the namesake, and donchian's first line is `upper`. Its per-line accessors —
`donchian_upper()`, `donchian_mid()`, `donchian_lower()` — are all callable, so the bare call is a
function-not-found on the first bar rather than a silent upper-band read. The template's own comment
says "multi-output indicators are not bound", which is wider than the rule the host applies.
> Show me the vike strategy templates and explain what each one trades.
## `list_indicators` [#list_indicators]
The **host-bound** callable set — what the Rhai engine actually registers, joined onto the
indicator catalogue's own metadata (`vike_indicators::registry()`). This is deliberately not the
whole catalogue: it is filtered to what a script can really call.
That question is `vike_script::is_callable`, and it has two arms — an indicator qualifies under
its bare name *or* through at least one per-line accessor. `bollinger` has no bare call and three
accessors, so it is listed and you reach it as `bollinger_mid(20)`.
The filter and the rows both live in `crates/vike-cli/src/cmd/indicators.rs`, which also feeds the
human `vike-cli indicators` listing, so the two surfaces cannot disagree about the built-in
catalogue. An indicator installed from `user_data/indicators` shows up in that listing and in no
answer this tool gives.
The answer is tiered, because the bound set is now most of the catalogue and a full dump would
spend an agent's context on indicators it will never use.
| Parameter | Type | Default | Notes |
| ----------- | ------ | ------- | ---------------------------------------------------------------------------- |
| `name` | string | *omit* | One indicator in full: its parameters with defaults and the value it returns |
| `category` | string | *omit* | One family in full; matched case-insensitively |
| *(neither)* | — | — | The compact roster: `name` + `category` per indicator |
**Returns** `indicators` plus a `count`. The compact roster also carries a `note` explaining how
to pull detail. A `name` or `category` nobody can serve is an `isError` result, not an empty list
— an empty answer reads as "that family is empty in this build" and sends the reader hunting
through a config. For a catalogued indicator the host deliberately holds back, the error quotes
the host's own reason (`vike_script::unbound_reason`) verbatim rather than paraphrasing it. That
arm fires only for a name callable under NO spelling — a Rhai reserved word, a host-function
collision, a batch-only indicator, or one with more parameters than the host binds. A name whose
bare call is refused but whose accessors work, like `bollinger`, never reaches it: it comes back
as an ordinary catalogue row.
Rhai resolves a registered function when the line *runs*, not at compile time, and the compile
path only runs a script's top level. So a misspelled indicator or a wrong arity compiles cleanly,
then raises `ErrorFunctionNotFound` on every bar. Argument type is the exception:
`register_arity_forms` binds every arity with `rhai::Dynamic` parameters, so `sma(5.0)` resolves
just like `sma(5)`, and only a non-numeric argument (`sma("20")`) fails — as a named runtime error
rather than function-not-found.
`crates/vike-script/src/strategy.rs`'s `RhaiStrategy` swallows each one fail-safe (zero orders
that bar) and, after 10 consecutive errors, disables the strategy outright. It looks mounted and
never trades. Check names against this tool, not against memory.
> Which indicators can a vike Rhai strategy call, and what parameters does `rsi` take?
# Backtesting (/docs/ai/trader/backtesting)
Five tools that run against a `vike-datahub` server at `--addr` (default `127.0.0.1:7878`). Three
of them run a strategy over history (`run_backtest`, `run_sweep`, `run_walk_forward`); two answer
what the server has to run it *with* (`list_strategies`, `list_series`). All five are read-only —
a backtest mutates nothing — and all five report an unreachable server as a clean tool error
(`cannot connect to datahub at …`), never a panic.
The three run tools take the same input: a **backtest profile TOML**, optionally with a Rhai
script injected into it. Unless you pass `script`, which makes the client parse and re-serialize
the TOML to inject it (`inject_script_src`), the profile is shipped to the server *verbatim*; and
either way the server is the only thing that reads its shape
(`crates/vike-backtest/src/harness/profile.rs`'s `from_toml_str`, which parses and validates in one
step) — which means a profile-shape error (no `[sweep]` table, a bad range, an unknown strategy)
surfaces from the server after the connect, not before it. One schema, one error source.
```toml
[data]
venue = "binance"
symbols = ["BTCUSDT"]
kind = "bar"
from = "0"
to = "100000"
[strategy]
name = "buy_hold"
[sweep]
fast = [5, 10]
```
## `run_backtest` [#run_backtest]
Run one backtest on the datahub from a profile TOML and return the report.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | ---------------------------------------------------------------------- |
| `profile` | string | yes | Backtest profile TOML: `[data]`, `[strategy]`, optional `[engine]` |
| `script` | string | no | Rhai source, injected as `[strategy.params].src` (`inject_script_src`) |
**Returns** `report` — the `BacktestReport` JSON as the server produced it.
The `script` argument is what lets an agent test a strategy it just wrote without ever writing a
file: author, `validate_strategy`, then hand the same source straight to this tool. An
unparsable profile is caught locally by the injection step; everything else is the server's answer.
> Backtest this SMA-cross script on BTCUSDT bars and show me the report.
## `run_sweep` [#run_sweep]
Run a parameter-grid sweep. The profile carries a `[sweep]` table whose every key overrides
`strategy.params.` across a grid of values, and the server ranks the results.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | ------------------------------------------------------------------------ |
| `profile` | string | yes | Profile TOML with `[data]`, `[strategy]` and `[sweep]` |
| `script` | string | no | Rhai source, injected as `[strategy.params].src` |
| `rank_by` | string | no | `sharpe` (default), `return`, `max_dd` or `equity` — applied server-side |
**Returns** `sweep` — the `SweepReport` JSON: one row per grid point, each carrying its own
`BacktestReport`, best first. Rows whose run failed always sort last, so the head of the list is
always a real result.
> Sweep fast over 5, 10, 20 and slow over 50, 100 and rank by Sharpe.
## `run_walk_forward` [#run_walk_forward]
Run an anchored out-of-sample walk-forward. The split count rides in the profile's
`[walkforward]` table (`n_splits`), not in an argument.
| Parameter | Type | Required | Notes |
| --------- | ------ | :------: | ------------------------------------------------------------ |
| `profile` | string | yes | Profile TOML with `[data]`, `[strategy]` and `[walkforward]` |
| `script` | string | no | Rhai source, injected as `[strategy.params].src` |
**Returns** `walkforward` — the stitched `WalkForwardReport` JSON: the OOS windows plus
`oos_sharpe` and `wf_consistency`.
The mode is always anchored (`vike_backtest`'s `run_walkforward`). Each window runs fresh at
`engine.cash` and the out-of-sample curves are rebased onto one running equity by the
`walk_forward_strategy` stitch. It is bar-mode over a single series by construction — a tick-mode
or multi-symbol profile comes back as a clean validation error, never a silent fall back to the
first symbol.
Run this before believing a sweep. A grid search returns its best row whether or not the edge
survives out of sample; this is the tool that asks.
> Walk this strategy forward over 6 anchored splits and tell me if the edge holds out of sample.
## `list_strategies` [#list_strategies]
The compiled **native** backtest strategies the server offers — the names a profile's
`strategy.name` can resolve (`vike_backtest::harness::STRATEGIES`, served over RPC). The list comes
from the server itself, so it reflects that build rather than this page. Takes no arguments.
**Returns** `strategies`, a list of names.
Native strategies and Rhai scripts are two different routes to the same engine: this tool answers
the first, `list_templates` the second.
> Which built-in strategies can I name in a profile?
## `list_series` [#list_series]
Every stored data series the server holds, with its coverage — what a profile's `[data]` table can
actually name. Takes no arguments. It reads the datahub's `Inventory` verb, the coverage-carrying
superset of a plain series list, so an agent can pick a `from`/`to` range instead of guessing one.
**Returns** `series`, one row per series: `kind`, `venue`, `symbol`, `interval`, `first_ts`,
`last_ts` and `rows`.
> What data do we have for BTCUSDT, and over what range?
# Drive Vike Trader from Claude (/docs/ai/trader/drive-vike-from-claude)
Every response on this page is copied verbatim from two captured sessions against
`vike-cli mcp`, driven over stdio with newline-delimited JSON-RPC 2.0. A local `vike-datahub` at
`127.0.0.1:7878` served the backtest. Nothing is illustrative: where a number looks unflattering,
it is the number the run produced. The one exception is the handshake below, which is re-rendered
whenever the server's `initialize` result gains a field — that result is fixed text assembled from
constants rather than anything a run produces, so a stale capture of it is simply a wrong payload.
It is pretty-printed here and arrives on one line; the key order is not a presentation choice —
the server builds its objects with `serde_json`'s default map, so the block below is in the order
the wire has it (sorted) rather than the order the code writes the fields.
[Client setup](/docs/ai/trader/setup) has the launch entry for your client. This page is what happens
after it launches.
## The handshake [#the-handshake]
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"docs-capture","version":"1"}}}
```
```json
{
"capabilities": {
"prompts": {},
"resources": {},
"tools": {}
},
"instructions": "This is `vike-cli`'s agent surface: ... \n\n ... Never invent a tool name, and never report an operator-side command as something you ran.",
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "vike-cli",
"version": "0.1.0"
}
}
```
Four things the server just told the client:
* `serverInfo.name` is `vike-cli`. The MCP server is not a separate binary — it is a subcommand of
the same CLI you use at a prompt, and the version is that crate's own.
* `capabilities` advertises `tools`, `resources` and `prompts`. No sampling to negotiate: the
server asks the client for nothing.
* The `protocolVersion` came back echoed, because the client declared one.
* `instructions` is a paragraph the client puts in front of the model — elided here, and the only
part of the handshake aimed at the model rather than at the client. It says this server is one
surface of a larger system and names the commands an **operator** runs for the rest: record a
tape with `vike-recorder`, get bars in with `vike-cli data fetch`, backtest with no server at all
with `vike-cli backtest --local`, and see which venue keys this box holds with
`vike-cli secrets list`. None of those is a tool, and the text says so — the point is that an
agent asked for one of them can now name the command instead of refusing blind.
⚠ **The instructions grant nothing.** Nothing in that paragraph is callable, and the roster below
is unchanged by it; it is also scoped by the same `ToolAccess` that decides the roster, so a
session launched `--profile read-only` is told less than this one — no preview gate, because it has
no write tool to gate. [`vike-cli mcp`](/docs/trader/reference/cli/mcp) has the scoping rule and what
each profile's text drops.
A `notifications/initialized` follows, carries no `id`, and gets no response.
## What the server offers [#what-the-server-offers]
`tools/list` returned the whole registry in one message — 9,859 characters, because every tool
ships its full `inputSchema` alongside its description. The names, in the order they arrived:
```text
validate_strategy discover_params list_templates list_indicators
run_backtest run_sweep run_walk_forward list_strategies
list_series node_snapshot submit_order cancel_order
modify flatten market_exit mass_cancel
set_trading_state
```
Ten read-only, seven that write to a node. [Authoring](/docs/ai/trader/authoring),
[backtesting](/docs/ai/trader/backtesting) and [trading](/docs/ai/trader/trading) are the reference pages
for the three groups.
Those 9,859 characters are a real cost: they land in the model's context whether a tool is called
or not. It is why the descriptions are terse, and why `list_indicators` — whose bound set is most
of the indicator catalog — names none of that set in its own description and makes you ask.
Terseness has a price of its own, though, and the number above is a floor rather than a budget: two
descriptions have been deliberately lengthened since this capture, each paid for by a measured
mistake. `discover_params` read as a tool for inspecting somebody else's script, so an agent that
had just written one answered "which parameters can I tune" from authorship instead of calling it;
`set_trading_state` said only "the kill switch", and an agent reached for it to put a venue live.
Both now say what the tool is *not* — the change is in `tools_spec`
(`crates/vike-cli/src/cmd/mcp.rs`), and it is a straight trade of context bytes for a failure that
had already happened.
## Starting from a template [#starting-from-a-template]
```json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_templates","arguments":{}}}
```
Three starters came back, each a row of two fields — a name and its source:
* SMA cross
* RSI reversion
* Donchian breakout
The first, verbatim:
```rust
let fast = param("fast", 5.0);
let slow = param("slow", 20.0);
fn on_bar() {
let f = sma(fast.to_int()); let s = sma(slow.to_int());
if s.is_nan() { return; }
let target = if f > s { 1.0 } else { -1.0 };
let delta = target - position();
if abs(delta) > 1e-12 { market(if delta > 0.0 { 1 } else { -1 }, abs(delta)); }
}
```
The third template ships a comment worth more than the strategy it belongs to. It uses an SMA as a
symmetric proxy, and says why:
> `donchian` is NOT bound: it is multi-output (upper/mid/lower) and the bridge binds single-output
> indicators only, so `donchian(len)` is a function-not-found at the first call rather than a
> silent upper-band read.
That is the failure mode the offline tools exist to catch. Rhai resolves a function name when the
line runs, so a script naming an unbound indicator compiles perfectly and then fails on every
single bar. See [list\_templates](/docs/ai/trader/tools/list_templates).
## The script this session wrote [#the-script-this-session-wrote]
```rust
// quiet_breakout — buy a breakout, but only out of a quiet bar.
//
// Two user indicators do the work: breakout_level for the level the bar has to clear, and
// range_pct to insist the bar that clears it is not already a wide, over-extended one.
let lookback = param("lookback", 48.0);
let calm = param("calm", 3.0);
let qty = param("qty", 0.05);
fn on_bar() {
// Read both every bar, before any branch — an indicator only advances when it is called.
let level = breakout_level(lookback.to_int());
let rng = range_pct();
if level.is_nan() || rng.is_nan() { return; }
if close() > level && rng < calm && position() <= 0.0 { market(1, qty); }
if close() < level * 0.97 && position() > 0.0 { market(-1, position()); }
}
```
Passed to `validate_strategy` — the wire form carries it as a JSON string with escaped newlines,
one message per line — the answer was two words long:
```json
{
"ok": true
}
```
`ok: true` is a narrower claim than it looks. The check runs through
`vike_script::discover_params`, the broker-free compile path: it compiles the script and runs its
top level *exactly once*, the same error surface as mounting the strategy. So the three `param()`
lines ran. The body of `fn on_bar()` did not.
`breakout_level` and `range_pct` are not built-ins. They are the author's own files, loaded from
`user_data/indicators/` and installed process-wide before any strategy compiles
(`install_user_indicators` in `crates/vike-script/src/engine.rs`). No compile check can vouch for
them; the backtest further down is what actually proved those two calls resolve.
One thing to carry into that backtest: the install that binds a name is the install on the box that
*compiles* the script. `validate_strategy` compiles in the CLI, so the CLI's set answers. A
`run_backtest` compiles on the datahub, so the *server's* set answers — `install_user_indicators` in
`crates/vike-datahub/src/datahub_cli.rs` reads that box's own `user_data/indicators/`, and its doc
says a client script binds to whatever the server holds there. Here the two agreed because the
datahub was the same machine.
[Writing an indicator](/docs/trader/scripting/writing-an-indicator) and
[loading indicators](/docs/trader/scripting/loading-indicators) cover that half.
## The knobs [#the-knobs]
`discover_params` on the same script:
```json
{
"params": [
{
"default": 48.0,
"name": "lookback"
},
{
"default": 3.0,
"name": "calm"
},
{
"default": 0.05,
"name": "qty"
}
]
}
```
Those three names are exactly what a profile's `[strategy.params]` or a `[sweep]` table may
override. Same compile path as the tool above, opposite disposition on failure: a broken script is
an `ok: false` *answer* from `validate_strategy` and a tool *error* from `discover_params`,
because there are no parameters left to report.
## What a compile error looks like [#what-a-compile-error-looks-like]
A second session opened by handing over a deliberately broken script:
```json
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"validate_strategy","arguments":{"script":"fn on_bar( { market(1 0.5) }"}}}
```
```json
{
"error": "Expecting ')' to close the parameters list of function 'on_bar' (line 1, position 12)",
"ok": false
}
```
That is the Rhai compiler's own message, passed through with the position it reported. It arrives
as an ordinary successful result — not a JSON-RPC error, not an `isError` result — so an agent
reads it, fixes the script and calls again. That loop is what
[validate\_strategy](/docs/ai/trader/tools/validate_strategy) is for.
## Asking about one indicator [#asking-about-one-indicator]
```json
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_indicators","arguments":{"name":"rsi"}}}
```
```json
{
"count": 1,
"indicators": [
{
"accessors": [],
"bare_call": true,
"category": "Momentum",
"name": "rsi",
"outputs": [
"rsi"
],
"params": [
{
"default": 14.0,
"max": 1000.0,
"min": 1.0,
"name": "length"
}
],
"pretty": "RSI"
}
]
}
```
Called with no arguments the tool returns the compact roster — name and category only — and detail
is pulled one `name` or one `category` at a time. The tiering is deliberate: the bound set was
three names when the tool was written and is nearly the whole catalog now.
The reason the tool exists at all is that the host-bound set is **not** the registry.
`crates/vike-cli/src/cmd/indicators.rs`'s `find_bound` separates the two ways a name can miss — a
typo, which gets "no indicator named `x`", and a real registry entry the host holds back, which
gets the host's own reason for holding it back. Different mistakes, different repairs.
## The call that failed [#the-call-that-failed]
Now the honest part of the session. The agent had a script, had its knobs, and asked for a
backtest by assembling arguments that *looked* like a backtest. The wire form was one line; the
script argument is elided here for length:
```json
{"name":"run_backtest","arguments":{
"script": "// quiet_breakout …",
"venue": "binance",
"symbol": "BTCUSDT",
"interval": "1h",
"from": "2025-01-01T00",
"to": "2026-01-01T00",
"cash": 10000,
"fee_rate": 0.001,
"params": {"lookback":48,"calm":3,"qty":0.05}
}}
```
Every one of those keys is plausible. Only `script` is real: the other eight are ignored, and the
call failed on the one argument the agent never sent. The answer:
```text
run_backtest requires a `profile` string argument
```
The real `inputSchema`, which had been sitting in the `tools/list` response the whole time:
```json
{
"properties": {
"profile": {
"type": "string"
},
"script": {
"type": "string"
}
},
"required": [
"profile"
],
"type": "object"
}
```
Two strings. Venue, symbol, interval, range, cash, fees and params all live *inside* the profile
TOML, because the server's own `BacktestProfile::from_toml_str` is what reads them. With no `script`
argument the profile crosses the wire byte for byte. With one, the client parses the TOML once to
inject the script and re-serializes it — but it still reads none of those fields, and the server
remains the only thing that interprets the profile.
Guessing an argument list is what any agent does when it pattern-matches a tool name instead of
reading its schema. What makes it cheap here is the answer: one line, naming the missing argument,
so the retry is obvious and costs one round trip. A tool error worth shipping is one an agent can
act on without a human.
## The corrected call [#the-corrected-call]
Same tool, same script, arguments rebuilt around the profile:
```toml
name = "quiet_breakout — BTCUSDT 1h"
[data]
kind = "bar"
interval = "1h"
venue = "binance"
symbols = ["BTCUSDT"]
from = "2025-01-01T00"
to = "2026-01-01T00"
[engine]
cash = 10000.0
fee_rate = 0.001
slippage = 0.0
[strategy]
name = "rhai"
[strategy.params]
lookback = 48.0
calm = 3.0
qty = 0.05
```
The script rides in the optional second argument and is injected as `[strategy.params].src`. Note
`strategy.name = "rhai"` — that is the one arm which reads an injected `src`, and getting it wrong
runs a built-in strategy while carrying the script along as an unread parameter.
[Run profiles](/docs/trader/backtesting/run-profiles) documents the file.
The report, verbatim:
```json
{
"report": {
"final_equity": 8334.568427500006,
"funding_paid": 0.0,
"max_drawdown": 0.1839635835571801,
"n_trades": 48,
"name": "quiet_breakout — BTCUSDT 1h",
"per_symbol_pnl": [
[
"BTCUSDT",
-1180.6035000000015
]
],
"profit_factor": 0.687335413608655,
"sharpe": -1.0475904521415624,
"total_return": -0.16654315724999946,
"win_rate": 0.3125
}
}
```
Forty-eight trades, a win rate of 0.3125, a profit factor below one, a Sharpe of −1.05 and a total
return of −0.167. A losing run, reported plainly. The profile *requested* a year of hourly bars; the
report carries no bar count or coverage field, so how much of that year the store actually held is
not something this capture states.
[Read a report](/docs/trader/guides/read-a-report) walks the numbers;
[the report](/docs/trader/analytics/report) defines each field.
## Why the agent's report is checkable [#why-the-agents-report-is-checkable]
The report above is not an MCP-flavoured summary of a run. It is the run.
* One function does the script injection — `inject_script_src` in
`crates/vike-cli/src/cmd/backtest.rs` — and the `backtest` subcommand and this tool both reach
it. So do `run_sweep` and `run_walk_forward`, through `crates/vike-cli/src/cmd/mcp.rs`'s shared
`profile_from_args`.
* Both then ship the resulting TOML text verbatim to `DatahubClient::run_backtest`.
* The server parses it once with `BacktestProfile::from_toml_str` and returns the report JSON,
which the tool wraps under `report` and hands back untouched.
So `vike-cli backtest` with the same profile and script, against the same `vike-datahub`, returns
the same numbers. The agent surface performs no arithmetic of its own, and that is the property
which makes it trustworthy: anything an agent quotes back to you can be reproduced at a prompt, by
you, without the agent.
## The half this session does not show [#the-half-this-session-does-not-show]
Seven of the seventeen tools write to a node. Six of them place, change or cancel orders —
`submit_order`, `cancel_order`, `modify`, `flatten`, `market_exit` and `mass_cancel` — and the
seventh, `set_trading_state`, places no order at all: it moves the account's trading state, the kill
switch. **This session called none of them.** No `vike-tradehub` node was running.
That absence does not, on its own, stop a call. Called the ordinary way — without `confirm: true` —
a write tool returns its preview, and with no node configured it opens no socket; the connection is
attempted only once a call has cleared the write gate, which is where the error naming the missing
`--node` arrives.
There is therefore no captured paper `submit_order` into `flatten` pair to show here, and
fabricating one would defeat the purpose of the page. What holds for those tools regardless:
* A write tool **executes nothing** until a call carries **both** `confirm: true` **and** the
`preview_token` that tool's own preview returned. Anything less returns a preview — the resolved
wire command, a client-side guardrail check, the node's own dry-run when there is a node to ask,
a note, and a fresh token — which the agent and the human watching it can both read before
anything is sent. The token fires once, expires after 60 seconds and is bound to the command it
previewed, so the two calls cannot be about two different orders. ⚠ Failing the gate is **not**
an error: a `confirm: true` with no token comes back as another preview, so `will_execute` is the
field to read, not the absence of `isError`.
* To *execute*, they need a running node named by `--node`, plus `VIKE_TRADEHUB_CONTROL_KEY`. The
read-only `node_snapshot` needs that same `--node`, with `VIKE_TRADEHUB_OBSERVE_KEY` in place of
the control key — and unlike a preview, it has nothing to answer with until the node is there.
* Nothing an MCP tool does can arm a venue. A venue with no credentials mounts as paper, and the
per-venue ceiling `policy.venues.` defaults to `paper` and can only ever refuse.
Read [submit\_order](/docs/ai/trader/tools/submit_order) and [flatten](/docs/ai/trader/tools/flatten) for the
argument shapes, and [trading](/docs/ai/trader/trading) for the preview gate and how outcomes are
reported.
## Next [#next]
* [Connect an agent](/docs/trader/guides/connect-an-agent) — the same loop as a narrative, from zero.
* [Authoring](/docs/ai/trader/authoring) — the four offline tools in full.
* [Backtesting](/docs/ai/trader/backtesting) — sweeps and walk-forwards, which take the same profile.
* [Client setup](/docs/ai/trader/setup) — the launch entry, per client.
# Trader MCP server (/docs/ai/trader)
`vike-cli mcp` is a local [Model Context Protocol](https://modelcontextprotocol.io/) server that
exposes the Vike trading platform as 17 tools. It is a binary on your own machine speaking
newline-delimited JSON-RPC 2.0 over stdio — your MCP client launches it as a subprocess, the same
way it would any other local server.
Ten tools are read-only: compile a Rhai strategy, discover its knobs and the indicators it may
call, run backtests, sweeps and walk-forwards against a `vike-datahub` server, and read a running
`vike-tradehub` node's live state. Seven write to that node: six place, change or cancel orders
and the seventh sets its trading state. Each one previews first, and the preview is not a
convention it can skip: a write tool executes only against a `preview_token` this server issued —
unexpired, and bound to the same command — so a first-and-only call returns the command it *would*
send, plus the token that would confirm it.
## Who this is for [#who-this-is-for]
Anyone running the Vike trading platform who would rather describe a strategy than write one, and
who wants the loop — author, compile, backtest, sweep, read the OOS windows, adjust — to happen
in a conversation instead of across six terminal invocations. [Connect an
agent](/docs/trader/guides/connect-an-agent) is the narrative walkthrough; this page and its three
siblings are the reference.
**No Vike account is involved.** Nothing here contacts `vike.io`; the server talks only to
services you run, and the credentials it uses are the HMAC keys of your own trading daemon. If you
do not have the platform yet, [Install with Docker](/docs/trader/getting-started/install-docker) is the
one-container route and [Quickstart: first backtest](/docs/trader/getting-started/quickstart-backtest)
gets a backtest running with zero credentials.
## Connect [#connect]
| Property | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Command | `vike-cli mcp [--addr ] [--node ] [--profile ] [--deny-tool ]… [--trace]` |
| Transport | stdio, newline-delimited JSON-RPC 2.0 — hand-rolled over `serde_json`, no MCP SDK |
| Server identity | `serverInfo.name` = `vike-cli`, version = the version of the binary you installed |
| MCP version | the client's `protocolVersion`, echoed back; `2024-11-05` when the client declares none |
| Capabilities | `tools`, `resources`, `prompts` |
| Methods | `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get` |
| Guidance | an `instructions` string in the `initialize` result — see below |
Any other method carrying an `id` is a `-32601`; a notification naming one gets no response line at
all, because there is no id to answer. An unparsable line is a `-32700` that answers rather than
killing the session. `tools/list` returns the registry in one response — every tool this server's
scope admits, with no pagination and no cursor.
Every successful tool result carries the same shape: the payload as pretty-printed JSON in one
`text` content block, plus the same value as `structuredContent`. A tool *failure* is an ordinary
result with `isError: true` and the message as text — not a JSON-RPC error — so the agent reads it
and retries. Even a tool that crashes internally arrives that way: `Server::handle`
(`crates/vike-cli/src/cmd/mcp.rs`) wraps every tool call in `catch_unwind`, so a panic comes back
as a tool error rather than taking the session down with it.
**[Client setup](/docs/ai/trader/setup)** has the exact entry per client — Claude Desktop, Claude Code,
Cursor, Gemini CLI, VS Code, Codex CLI, Amazon Q Developer CLI and Windsurf — plus the raw stdio
protocol for scripting or for diagnosing a client that will not launch the binary.
## What the server says about itself [#what-the-server-says-about-itself]
The `initialize` result carries an `instructions` string, and a client shows it to the model as
guidance about this server. It is the one channel that reaches the model **unasked** — in front of
the session, before any call — and it carries a fact a tool description cannot: a description
describes *its* tool, so "there is no tool for this, and the command is `vike-recorder`" — a fact
about the **absence** of a tool — has nowhere in a roster to live. (The prompts below say a related
thing, but only to a client that asks for one.)
Its content is one line saying this server is part of a larger system, one line each for the things
that live outside it — recording a tape, getting bars into the history store, backtesting with no
data service, and which venue keys the box holds — each naming the command an **operator** types,
and two rules: invent no tool name, and never report an operator-side command as something you ran.
⚠ **It is guidance, not capability.** Nothing in that text is callable and no gate on this page
moves because of it; an agent gains only the ability to say what an operator should run when the
answer is that no tool here does it. It is also **profile-scoped by the same `ToolAccess`** as the
roster — `read-only` sends no preview-gate sentence, `offline` also drops the one naming
`list_series` — because prose that names a withheld tool advertises a withheld tool.
[`vike-cli mcp`](/docs/trader/reference/cli/mcp#what-initialize-tells-the-model) is the reference, and
[Raw JSON-RPC](/docs/ai/trader/setup/raw-http#the-handshake) shows where the field sits in the response.
## Two flags decide what the tools can reach [#two-flags-decide-what-the-tools-can-reach]
| Flag | Default | What it reaches |
| -------- | ---------------- | --------------------------------------------------------------------------------------- |
| `--addr` | `127.0.0.1:7878` | the `vike-datahub` server the backtest and inventory tools run against |
| `--node` | *none* | the running `vike-tradehub` node the snapshot and order-write tools observe and control |
`--node` has no default on purpose. Without it the nine authoring and backtesting tools work
exactly as they do with it, and `node_snapshot` returns a clean error naming the flag: the check
runs before any socket is opened, so you get an immediate answer rather than a connection timeout.
A write tool is quieter about it — a call that fails the two-call gate returns a preview whatever
the flag says, and even a *confirming* one only reaches the connection error after its
`preview_token` has been accepted. **So no preview is evidence the flag arrived; a node snapshot
is.**
## Two more decide which tools exist at all [#two-more-decide-which-tools-exist-at-all]
The preview gate makes a write take two calls. `--profile` answers the prior question — whether this
server offers the write tools in the first place — and only the operator can answer it, in the MCP
client's launch command.
| Profile | Serves | Withheld |
| ---------------------------- | ------ | ------------------------------------------------------------------------------------------------------------ |
| `full` *(default)* | all 17 | nothing — byte-identical to the surface that shipped before the flag existed |
| `read-only` | 10 | the seven order-write tools |
| `offline` | 4 | everything that opens a socket: the write seven, the five datahub tools, `node_snapshot`, and both resources |
`--deny-tool NAME` subtracts one more tool from whatever the profile allows, and is repeatable. An
unknown profile name or an unknown deny name is a usage error naming the valid set, never a fallback
to something wider.
**A withheld tool is absent from `tools/list` as well as refused by `tools/call`**, and the two
cannot disagree because they are one derivation: `ToolAccess::new`
(`crates/vike-cli/src/cmd/mcp.rs`) makes a single pass over the registry and produces two
complements — the advertised set and its withheld complement. Each ring is a predicate over data
that already existed rather than a second roster: `read-only` is the negation of `is_write_tool`,
the same `WRITE_TOOLS` array the preview gate routes on, and `offline` is each tool's own
`openWorldHint` annotation read back, with a tool that declares nothing withheld rather than
admitted.
The two **resources** follow the same predicate, because a resource is a second way *in* to a tool's
implementation rather than a second source — `vike://node/snapshot` routes into `node_snapshot`, so
an unscoped resource list would hand back a withheld tool's answer through a URI. Neither is a
write, so `read-only` withholds neither; `offline` withholds both. The **prompts** are re-worded
instead of withheld, because a prompt executes nothing and a sheet that vanishes teaches an agent
less than one saying the tools are not served here: a session admitting no write opens every prompt
with a banner saying so, and renders no two-call instructions for tools it has just said are absent.
The `initialize` **instructions** above answer to the same predicate, but by subtraction rather than
re-wording: a scoped session is simply sent fewer clauses, losing the sentence that names whatever
it cannot reach. Either way, every channel that reaches the model is a place a withheld tool could
otherwise be advertised.
A refusal names the profile, lists what *is* served, and says plainly that only the operator can
widen it — otherwise an agent spends its next three turns hunting for the flag that turns the tool
on.
## The agent transcript [#the-agent-transcript]
`--trace` (or `--trace-dir `) appends one JSON Lines record per `tools/call`:
the tool, whether it is a write, the profile, the arguments, the verdict, and for a gated write the
preview token's identity. It is **off unless you ask for it** — no flag, no file, no directory
created. `crates/vike-cli/src/cmd/mcp_trace.rs` is the writer.
The verdict is `ok`, `preview`, `refused` or `error`, and **`refused` is the half no other record
holds**: a refusal never reaches the node's audit trail, because it was refused before a byte left
this process. `preview` is kept apart from `ok` for the same kind of reason — "the agent called
`submit_order`" and "an order left this process" are different facts.
Nothing secret is written. Argument values are redacted through the workspace's one credential-name
authority, applied both to object keys and to credential-shaped SHOUTING\_CASE identifiers inside
string values, so a `reason` quoting an API-key name has the whole value replaced. The `preview_token` **argument** is
redacted by that first rule like any other token-shaped key; what the record carries instead is its
own `token` field holding the token's **identity** — `pv-3`, the per-session counter — plus whether
this call minted it or presented it. That is a sequence rather than an authorization, which is
exactly why there is nothing there to withhold. Records land beside the change journal, under
`/settings/state/agent/`, one file per month, pruned to the newest 24.
## Running it on a schedule [#running-it-on-a-schedule]
Nothing about this server requires a human in the conversation. `vike-agent-run` — a second binary,
`crates/vike-agent-eval/src/bin/vike-agent-run.rs` — stands the same server up against your own
node, hands one prompt to one model, bounds the session, and writes a record of what happened. It
is **read-only unless you pass `--allow-writes`**, and the ring it asked for is cross-checked
against the roster the server advertised back before the model is asked anything. Scheduling itself
is delegated outright to the operating system — systemd unit and timer templates ship, and the
timer deliberately does not replay a firing missed while the box was down, because a review of a
moment that has passed is a record about nothing. No account, no hosted agent, nothing on a Vike
machine: your box, your node, your schedule.
[Unattended runs](/docs/ai/trader/unattended) is the page.
## Node keys, and what is disabled without them [#node-keys-and-what-is-disabled-without-them]
The node tools authenticate with two HMAC keys, resolved from the **process environment first**
and then from the same credential store the daemon itself reads
(`/settings/secrets.env`) — `resolve` in `crates/vike-cli/src/cmd/nodekeys.rs` is the
whole rule:
| Key | Grants | Used by | Absent means |
| --------------------------- | ------ | --------------------------- | ---------------- |
| `VIKE_TRADEHUB_OBSERVE_KEY` | read | `node_snapshot` | 1 tool disabled |
| `VIKE_TRADEHUB_CONTROL_KEY` | write | the seven order-write tools | 7 tools disabled |
Each is resolved independently, so the two halves fail separately: an observe key with no control
key gives an agent a read-only view of the live node and no way to touch it, which is a reasonable
configuration to run on purpose. **Neither key affects the other nine tools** — authoring and
backtesting never open a node connection.
An `export` wins over the store because it is the narrower, more deliberate source — that is what
makes "point at a different node for one command" possible. Values are trimmed on both sides — the
daemon trims too — so a key with a trailing newline would otherwise fail the handshake as an opaque
`AuthDenied`. The daemon's own reader is `from_vars` in `crates/vike-tradehub-client/src/auth.rs`,
and `values_are_trimmed_to_match_the_servers_own_reader` in `nodekeys.rs` holds the two readers to
the same bytes from the same map.
A key that is in neither place produces an error naming *both* places — run `vike-cli secrets
path` to see which store this project resolves to and `vike-cli secrets list` to see whether the
key is in it (names only, never values). The two scopes are separate at the node as well as here:
an observe-scoped connection that sends a command is refused outright by the daemon, so an
observe key can never be escalated into a write. `observe_scope_command_is_denied` in
`crates/vike-tradehub/tests/control_roundtrip.rs` is that refusal under test: it authenticates as
observe against a node whose control key *is* enabled, sends a cancel, and requires the denial.
Claude Desktop, Cursor and VS Code start the server as a child of the *application*, not of your
terminal, so an `export VIKE_TRADEHUB_CONTROL_KEY=...` in your shell profile is invisible to it.
Put the keys in the credential store, or set them in the client's own config where it supports an
`env` block. The store is also where the daemon reads them, so one copy serves both halves.
## The 17 tools [#the-17-tools]
| Tool | Plane | Reaches |
| -------------------------------------------- | --------- | ----------------------------- |
| [`validate_strategy`](/docs/ai/trader/authoring) | read | nothing — offline |
| [`discover_params`](/docs/ai/trader/authoring) | read | nothing — offline |
| [`list_templates`](/docs/ai/trader/authoring) | read | nothing — offline |
| [`list_indicators`](/docs/ai/trader/authoring) | read | nothing — offline |
| [`run_backtest`](/docs/ai/trader/backtesting) | read | datahub (`--addr`) |
| [`run_sweep`](/docs/ai/trader/backtesting) | read | datahub (`--addr`) |
| [`run_walk_forward`](/docs/ai/trader/backtesting) | read | datahub (`--addr`) |
| [`list_strategies`](/docs/ai/trader/backtesting) | read | datahub (`--addr`) |
| [`list_series`](/docs/ai/trader/backtesting) | read | datahub (`--addr`) |
| [`node_snapshot`](/docs/ai/trader/trading) | read | node (`--node` + observe key) |
| [`submit_order`](/docs/ai/trader/trading) | **write** | node (`--node` + control key) |
| [`cancel_order`](/docs/ai/trader/trading) | **write** | node (`--node` + control key) |
| [`modify`](/docs/ai/trader/trading) | **write** | node (`--node` + control key) |
| [`flatten`](/docs/ai/trader/trading) | **write** | node (`--node` + control key) |
| [`market_exit`](/docs/ai/trader/trading) | **write** | node (`--node` + control key) |
| [`mass_cancel`](/docs/ai/trader/trading) | **write** | node (`--node` + control key) |
| [`set_trading_state`](/docs/ai/trader/trading) | **write** | node (`--node` + control key) |
## Read or write [#read-or-write]
The split is exactly ten and seven, and it is machine-pinned rather than conventional: every
read-only tool carries `readOnlyHint: true` and every write tool carries `destructiveHint: true`
in `tools/list`, and `tools_list_has_read_and_write_tools_with_correct_hints`
(`crates/vike-cli/src/cmd/mcp.rs`) walks every advertised tool and fails if one falls on the wrong
side — it also requires each write tool's description to mention `confirm`. The seven write tools
are exactly the write verbs of the human `trade` REPL, and both surfaces build their wire commands
through one construction site (`Verb::to_wire_command` in `crates/vike-cli/src/cmd/verbs.rs`, which
the tools reach through `wire_command_for` — JSON arguments resolved into the same `Verb` the REPL
parses a typed line into), so the two cannot drift apart.
Three properties follow from that, and they are what makes handing these tools to an agent
defensible:
* **A write executes only against a `preview_token`.** `confirm: true` is one half of the gate and
the token this tool's own preview returned is the other; either one missing returns a preview —
the resolved wire command, a client-side guardrail check, the node's own dry-run when there is a
node to ask, the rationale that would be recorded, and a fresh token. The token fires once,
expires after 60 seconds, and is bound to the command it previewed, so a preview of one order
cannot confirm another.
* **The client never enforces.** The preview's guardrail is advisory UX. What refuses an oversized
or too-frequent command is server-side on the node — `ControlLimits::vet` in
`crates/vike-tradehub/src/server.rs`, a per-order notional ceiling plus a command rate limiter —
and beneath it the execution core's `RiskGate` (`crates/vike-exec/src/risk.rs`). Nothing an agent
sends from here can loosen either. Both names come back in the tools' own output — every preview
and every accepted response names them as the enforcing gate.
* **Each write reports its own command's fate.** The server waits on the ticket its own send
returned (`execute` in `crates/vike-cli/src/cmd/mcp.rs`), so `accepted` / a named refusal /
`unknown` always describes the command just issued, never a neighbouring one.
Every way of failing this gate produces a **preview**, not an error: a missing `preview_token`, a
`confirm` that is the string `"true"` rather than the JSON boolean (or `1`, or `"yes"`), or both.
Nothing is sent, which is the safe outcome, but nothing says so either — an agent that believes it
confirmed and reads back `will_execute: false` has not. Only a token that is unknown, already used,
expired or bound to a different command comes back flagged.
The mechanics — the preview payload field by field, what the node's two limits actually reach,
client-order-id handling, and what `unknown` obliges a caller to do — are on
[Live node](/docs/ai/trader/trading).
# Live node (/docs/ai/trader/trading)
Eight tools that need `--node ` and a key. One reads the node's live state
(`node_snapshot`, observe key); the other seven write to it (control key) — six place, change or
cancel orders, and `set_trading_state` moves the account's trading state. Every write tool previews
by default: a first call resolves the command, shows it, and sends nothing. Executing takes a
second call carrying **both** `confirm: true` **and** the `preview_token` that first call returned
— anything less is another preview.
Everything on this page travels to the node over a wire that is authenticated but **not encrypted**,
and the HMAC covers the connection rather than each frame — so orders, positions and symbols are
readable by anyone who can read the traffic, and a remote node belongs behind an ssh tunnel or a
VPN. [Client setup](/docs/ai/trader/setup) states the limit in full;
[Remote node](/docs/ai/trader/setup/remote-node) is the tunnel recipe.
The seven are the full write-verb roster of the human `trade` REPL, and both surfaces build their
commands at one site — `crates/vike-cli/src/cmd/verbs.rs`'s `Verb::to_wire_command`, which the MCP
tools reach through `wire_command_for`: JSON arguments in, the same wire command the REPL would
build out. An agent and an operator are issuing literally the same commands.
Nothing holds the count at seven at build time. What holds it is a unit test over the advertised
tool list — `tools_list_has_read_and_write_tools_with_correct_hints` in
`crates/vike-cli/src/cmd/mcp.rs` — which requires every tool outside its seven-name roster to carry
`readOnlyHint`. An eighth write verb turns that test red until somebody widens the roster
deliberately.
The gate below is what a write costs when the tools are served. Whether they are served at all is
the operator's call, at launch: `vike-cli mcp --profile read-only` serves the ten read tools and
withholds all seven of these, which are then **absent from `tools/list`** as well as refused by
`tools/call` — `ToolAccess::new` (`crates/vike-cli/src/cmd/mcp.rs`) derives the advertised roster
and the routing gate from one pass, so they cannot disagree. `--deny-tool NAME` withholds one more,
repeatably; `--profile offline` leaves only the four authoring tools and withholds both resources
too, since each routes into a tool that opens a socket. The prompts stay, re-worded: a session with
no admitted write opens each one with a banner saying so instead of teaching a two-call procedure
whose first call would be a refusal. Nothing an agent sends can widen this, and the refusal says so.
[Client setup](/docs/ai/trader/setup#the-command-and-its-flags) has the config entry.
## `node_snapshot` [#node_snapshot]
The running node's live state: open orders, positions, per-venue equity and recent events. Takes
no arguments; needs `--node` and `VIKE_TRADEHUB_OBSERVE_KEY`.
**Returns** the node's snapshot frame — `WireSnapshot`, in
`crates/vike-tradehub-client/src/wire.rs` — serialized as-is, so the response's field set is that
struct's.
The observe connection is opened lazily and kept open — it stays subscribed so the node keeps
pushing frames. A freshly-subscribed connection answers with an empty placeholder (`seq` 0) until
the first coalesced frame lands, so the tool waits up to about two seconds for a real one before
returning.
This is also the tool that settles an ambiguous write. See [Outcomes](#outcomes).
> Show me the node snapshot — what is open and where is the equity?
## How a write call works [#how-a-write-call-works]
Two calls, deliberately — and the second one carries **two** things, not one.
**Call one — the preview.** The server builds the wire command from your arguments, mints a client
order id if one is needed, asks the node what it would do with that command, and returns the answer
having sent nothing. The payload itself is assembled by `preview_of` in
`crates/vike-cli/src/cmd/mcp.rs`, which is pure — the node question happens before it, in
`Server::node_preview` — and it emits exactly these fields:
| Field | What it holds |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| `will_execute` | always `false` on this path |
| `tool` | the tool name being previewed |
| `wire_command` | the fully resolved command, exactly as it would go on the wire |
| `guardrail` | the client-side advisory check (below) |
| `node_verdict` | the node's own dry-run, or `null` when there was no node address or no control key to ask with |
| `verified_by_node` | whether the node was *asked* |
| `preview_token` | **the token that confirms this exact command** |
| `reason` | the rationale that would be recorded — trimmed, `null` when blank or absent |
| `note` | what a confirming call needs, the client-order-id rule, and where enforcement actually lives |
**Call two — the execution.** The same arguments plus **both** `confirm: true` **and** the
`preview_token` the preview handed back. Either one missing returns another preview and sends
nothing:
```json
{ "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.01,
"confirm": true, "preview_token": "pv-1" }
```
`verified_by_node` is `true` as soon as there was a node address and a control key to ask with — it
does **not** promise the node replied. A handshake or transport fault is not a verdict, so
`node_verdict.checked_by` is the field that settles it: `"node"` means the node answered and
`accepted` is its own judgement, `"none"` means it could not be reached and `reason` names the
fault. An absent verdict must never be read as an approving one, which is also why the preview's
`note` says in words that the guardrail is an unverified client-side estimate whenever the node was
not asked (`a_preview_with_no_node_says_it_was_not_verified` pins that wording).
### What the token is, and what it is not [#what-the-token-is-and-what-it-is-not]
The token is `pv-`, minted by `PendingPreviews::issue` and bound to the command it
previewed. It is **not a secret and does not need to be** — this is a stdio server, and the only
party that can send it a request is the agent already holding both ends of the pipe, so there is no
third party to withhold it from. What it proves is that a preview *happened for this exact
command*: it encodes a sequence, not an authorization. `PendingPreviews`' own doc argues that at
length, and a counter is all the property needs.
Four rules follow, and each is enforced rather than advised:
* **It fires once.** `PendingPreviews::take` removes the entry *before* the command is executed, so
a duplicated call finds nothing and gets `unknown or already used`. Pinned by
`a_preview_token_fires_at_most_once`.
* **It expires after 60 seconds** — `PREVIEW_WINDOW`, the same window the Telegram surface uses —
because a preview priced against one book must not be confirmable against a moved one. An expired
token is consumed as it is rejected, so there is no second attempt at it either.
* **It is bound to its own command.** `same_intent` compares the whole wire command and ignores
exactly one field, the freshly minted `client_order_id`. Confirming a *different* command with a
live token is refused — and consumes it. Pinned by
`a_preview_token_is_bound_to_the_command_it_previewed`.
* **It dies with the session.** The store is in-process memory, so a token from a previous
`vike-cli mcp` run confirms nothing.
What actually reaches the venue is the **stored** command, not the one the confirming call rebuilt.
They express the same intent — that was just checked — but only the stored one carries the client
order id the preview displayed and the node dry-ran, so what is sent is exactly what was shown.
Sending `confirm: true` without a `preview_token` does **not** fail. It returns a fresh preview,
`will_execute: false`, a new token, and nothing on the wire. An agent that treats a confirming call
as evidence it traded — without reading `will_execute` — will believe it placed an order that does
not exist. `confirm_alone_no_longer_executes_it_returns_a_preview` is that behaviour under test.
`confirm` is read as a JSON boolean and nothing else, so the string `"true"` — and `1`, and `"yes"`
— fall through to the preview. That failure is safe (nothing is sent), but it is silent: check
`will_execute` in the response rather than assuming.
An **unknown**, **expired** or **mismatched** token is the one shape that is loud: those three are
tool errors carrying the reason, not previews. So the quiet answer always means "nothing happened",
and the noisy one always tells you why.
### The rationale [#the-rationale]
Every write tool takes an optional `reason` string — why the agent is issuing this command. It
rides *beside* the command on the wire, never inside it: `reason_from_tool_args`
(`crates/vike-cli/src/cmd/verbs.rs`) reads it separately from the arguments the command is built
from, so no `reason` you pass can change what gets sent.
It is echoed in the preview, sanitized on the node by `sanitize_reason`
(`crates/vike-tradehub/src/audit.rs`), and lands in the node's audit trail: control characters are
stripped and the text is capped at 512 characters (`MAX_REASON_CHARS`). It never reaches the order,
the core fold, the journal or a venue.
### What the node's trail cannot hold, and where it goes instead [#what-the-nodes-trail-cannot-hold-and-where-it-goes-instead]
That trail records one line per **accepted** command, which leaves out the half an operator wants
after a bad night: what the agent *tried*. A refusal never reaches it at all — it was refused before
a byte left the MCP process. `vike-cli mcp --trace` is the record that does hold it
(`crates/vike-cli/src/cmd/mcp_trace.rs`): one appended JSONL entry per tool call, carrying the tool,
the write bit, the profile, the arguments and a verdict of `ok`, `preview`, `refused` or `error` —
with `preview` kept apart from `ok`, because "the agent called `submit_order`" and "an order left
this process" are different facts. For a gated write it records the preview token's **identity** in a
field of its own — `pv-3`, the same per-session counter this page argues is a sequence and not an
authorization — plus whether this call minted or presented it. The `preview_token` *argument* is
redacted, along with every credential-shaped key and every credential-shaped SHOUTING\_CASE
identifier appearing inside a string value, so a `reason` quoting an API-key name has the whole
value replaced.
It is **off unless the flag is given** — no file, no directory — and writes under
`/settings/state/agent/`, beside the change journal.
## What actually enforces [#what-actually-enforces]
The preview's `guardrail` is **advisory UX computed on your machine** — `guardrail_check` in
`crates/vike-cli/src/cmd/verbs.rs`. Only a submit has a size to check; every other verb reports
`within_limits: true` with a note saying there was nothing to size. Its two caps come from
different places: `max_qty` from `VIKE_MAX_ORDER_QTY` (a local typo-catcher with no enforcing
counterpart anywhere) and `max_notional` from *this machine's* `policy.toml`
(`max_notional_per_order`). A market order carries no price, so its notional is `null` and cannot
be checked client-side at all.
What refuses a command is server-side, on the node, and an agent cannot reach it. The first two are
one function — `ControlLimits::vet` in `crates/vike-tradehub/src/server.rs`, which takes a rate
token before it sizes anything:
| Gate | Where | Reach |
| ------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Rate limiter | `vet`, at the node's control edge | a token is consumed on **every** command, per control connection; the rate comes from `VIKE_TRADEHUB_CONTROL_RATE` |
| Notional cap | `vet`'s notional half, same edge | **only** order-increasing verbs carrying a price: `submit_order`, and `modify` when it names both a new qty and a new price. The ceiling is the *node's* `max_notional_per_order`, **unset by default** — a node whose `policy.toml` sets none skips this half entirely |
| Risk gate | `RiskGate`, in the execution core (`crates/vike-exec/src/risk.rs`) | beneath both, and the gate that resolves a resting order's terms |
So `flatten`, `mass_cancel`, `market_exit`, `cancel_order` and `set_trading_state` are rate-limited
but never size-checked — they only reduce exposure, which is the right way round.
One shape fails closed rather than passing: on a node that sets a notional ceiling, a `modify` that
names a new quantity but **no** price is refused, because the control edge holds no order registry
and cannot price the projected order — and "I cannot evaluate this ceiling" must never render as
"the ceiling passed". Re-send the modify with an explicit price. (Before that arm existed, a small
in-cap order could be modified straight past the node's one order-size ceiling.)
Nothing an MCP tool does can arm a venue, either: a venue with no credentials mounts as paper, and
the per-venue ceiling `policy.venues.` defaults to `paper` and can only ever refuse — it
lowers what credentials would otherwise allow and can never raise it. That fold is
`vike_config::VenueMode::cap` (`crates/vike-config/src/venue_mode.rs`), a minimum and never a
maximum. The [Connect an agent](/docs/trader/guides/connect-an-agent) guide states that half of the
model.
## Outcomes [#outcomes]
A confirmed write reports **its own command's** fate — the server waits for the acknowledgement of
the exact command it sent, so an answer can never describe some earlier command. It waits two
seconds — `ACK_WAIT` in `crates/vike-cli/src/cmd/mcp.rs` — which is a deadline and not a sleep: the
answer returns the instant the node's reply lands.
| What happened | Shape | What to do |
| ------------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Node accepted | result with `sent: true`, `outcome: "accepted"`, `client_order_id` | call `node_snapshot` to watch the result. An empty `client_order_id` means an account-wide verb |
| Node refused | `isError` — `node rejected the command: ` | read the reason; it is the node's, usually a rate or notional refusal |
| No answer in 2s | result with `sent: true`, `outcome: "unknown"` | **may still execute.** Call `node_snapshot` **before** retrying |
| Connection dropped | `isError` naming the outcome as unknown | **may have executed.** Call `node_snapshot` **before** retrying |
The two ambiguous cases arrive in two different shapes. A timeout is a *successful* tool result
carrying `outcome: "unknown"`; a dropped control connection is an `isError` result whose text says
the outcome is unknown and that it may have executed. An agent that only inspects `outcome` will
see the first and miss the second — treat both as "check the node before retrying".
## Client order ids [#client-order-ids]
The node refuses a remote submit with an empty `client_order_id` — proven against a running node by
`submit_with_empty_coid_is_rejected` in `crates/vike-tradehub/tests/control_roundtrip.rs`. So when
the agent omits the argument this server mints one, off the same generator and wire form the live
core uses, *before* the preview renders: `fill_client_order_id`
(`crates/vike-cli/src/cmd/verbs.rs`), the one mint on this side of the wire. The preview therefore
shows a real id.
**The id the preview showed is the id that gets sent**, and you do not have to pass it back. A
confirming call does mint a fresh one while rebuilding the command — `fill_client_order_id` mints
on every call — but that rebuilt command is only ever compared, never executed: `same_intent`
excludes the id precisely so a confirm can match, and the server then sends the **stored** command.
So the id the node dry-ran and the preview displayed is the one that reaches the venue. The
`client_order_id` in the accepted response is still the authoritative one to hold on to.
Sending the same id twice is not a "did it land" test, and not free: the node acknowledges each
send, and the core registry is coid-keyed, so the snapshot shows exactly one order
(`duplicate_coid_is_idempotent`, in that same test file — it counts snapshot rows, not venue
orders). The second submit still reaches the client, so on a paper mount two orders rest.
## `submit_order` [#submit_order]
Submit an order. Requires `--node` and the control key.
| Parameter | Type | Required | Notes |
| ----------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- |
| `venue` | string | yes | Venue id |
| `symbol` | string | yes | Instrument symbol |
| `side` | integer | yes | `1` buy, `-1` sell |
| `qty` | number | yes | Order quantity |
| `order_type` | string | no | `market` (default), `limit`, `stop`, `take_profit` |
| `price` | number | no | Limit price |
| `trigger_price` | number | no | Stop / take-profit trigger |
| `reduce_only` | boolean | no | Defaults to `false` |
| `client_order_id` | string | no | Accepted by the builder though not advertised in the tool's schema; minted when absent |
| `reason` | string | no | Recorded in the node's audit trail |
| `confirm` | boolean | no | Must be the JSON boolean `true` **and** be accompanied by `preview_token` — either one missing returns a preview |
| `preview_token` | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
A missing required field is a tool error before anything is built.
> Preview a limit buy of 0.5 BTCUSDT at 60,000 on binance.
## `cancel_order` [#cancel_order]
Cancel one resting order by client order id.
| Parameter | Type | Required | Notes |
| ----------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- |
| `client_order_id` | string | yes | The id to cancel |
| `reason` | string | no | Recorded in the node's audit trail |
| `confirm` | boolean | no | Must be the JSON boolean `true` **and** be accompanied by `preview_token` — either one missing returns a preview |
| `preview_token` | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
> Cancel order a1b2c3d4-7 — it is stale after the feed gap.
## `modify` [#modify]
Change one resting order's quantity and/or price. At least one of `new_qty` / `new_price` is
required; a modify that changes nothing is an authoring mistake, and the tool says so.
| Parameter | Type | Required | Notes |
| ----------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- |
| `client_order_id` | string | yes | The order to modify |
| `new_qty` | number | no | New quantity |
| `new_price` | number | no | New price |
| `reason` | string | no | Recorded in the node's audit trail |
| `confirm` | boolean | no | Must be the JSON boolean `true` **and** be accompanied by `preview_token` — either one missing returns a preview |
| `preview_token` | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
Name a price whenever you name a new quantity — on a node with a notional ceiling, a qty-naming
modify with no price is refused, as described above.
> Move my resting bid to 59,800 and cut the size to 0.2.
## `flatten` [#flatten]
Close one `(venue, symbol)` net position with a reduce-only market order.
| Parameter | Type | Required | Notes |
| --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- |
| `venue` | string | yes | Venue id |
| `symbol` | string | yes | Instrument symbol |
| `reason` | string | no | Recorded in the node's audit trail |
| `confirm` | boolean | no | Must be the JSON boolean `true` **and** be accompanied by `preview_token` — either one missing returns a preview |
| `preview_token` | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
> Flatten the BTCUSDT position on binance.
## `mass_cancel` [#mass_cancel]
Cancel every live order, optionally scoped. Omit `venue` for every venue and `symbol` for every
symbol — omitting both is the whole account, and the preview shows exactly that (both fields
`null` in the wire command).
| Parameter | Type | Required | Notes |
| --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- |
| `venue` | string | no | Omit for every venue |
| `symbol` | string | no | Omit for every symbol |
| `reason` | string | no | Recorded in the node's audit trail |
| `confirm` | boolean | no | Must be the JSON boolean `true` **and** be accompanied by `preview_token` — either one missing returns a preview |
| `preview_token` | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
> Cancel all resting orders on okx.
## `set_trading_state` [#set_trading_state]
Set the account trading state — the kill switch. It is **not** a venue arming control and cannot
move a venue off the paper simulator: that is `policy.venues.` under `[venues]` in
`/settings/policy.toml`, a file a human edits on the box and that nothing on this server
can change. The tool's own description carries that sentence, because this is the one name on the
roster that reads like "go live" — [`set_trading_state`](/docs/ai/trader/tools/set_trading_state) has
the argument, and [Arm one venue](/docs/trader/guides/arm-a-venue) is where arming is actually done.
| Parameter | Type | Required | Notes |
| --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- |
| `state` | string | yes | `active`, `reducing` or `halted`; anything else is a tool error naming the three |
| `reason` | string | no | Recorded in the node's audit trail |
| `confirm` | boolean | no | Must be the JSON boolean `true` **and** be accompanied by `preview_token` — either one missing returns a preview |
| `preview_token` | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
> Halt trading — I want to look at the last hour before anything else goes out.
## `market_exit` [#market_exit]
Cancel every live order and then flatten every one-way position, optionally scoped to one venue —
hedge-mode `LONG`/`SHORT` legs are skipped.
| Parameter | Type | Required | Notes |
| --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- |
| `venue` | string | no | Omit for every engine |
| `reason` | string | no | Recorded in the node's audit trail |
| `confirm` | boolean | no | Must be the JSON boolean `true` **and** be accompanied by `preview_token` — either one missing returns a preview |
| `preview_token` | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
`market_exit` is preview-gated like every other write tool, and its own description calls it the
panic button. Treat it as the incident tool it is: it is the one verb that empties the book and the
one-way positions in a single command.
> Market exit everything on binance, right now — reason: feed outage.
# Unattended runs (/docs/ai/trader/unattended)
`vike-agent-run` runs **one** agent session against a node *you* are running, and exits. It is the
local answer to "look at my node while I sleep": there is no account, no hosted agent, no
deployment and no control plane, and nothing runs on a Vike machine — the only thing that leaves
your box is the request to whichever model endpoint you chose.
```sh
vike-agent-run run \
--task node-review \
--node 127.0.0.1:7879 \
--record-dir /srv/vike-agent-run/settings/state/agent \
--budget 900
```
That is the whole product. The runner stands up the same [`vike-cli
mcp`](/docs/trader/reference/cli/mcp) server your MCP client would launch, hands one prompt to one
model, bounds the session, and writes down what happened
(`crates/vike-agent-eval/src/bin/vike-agent-run.rs` is the binary,
`crates/vike-agent-eval/src/unattended.rs` the session it drives).
It works against **your** node because it is pointed at it: `--node` is the address of your running
[`vike-tradehub`](/docs/trader/reference/binaries/vike-tradehub), and the node's observe and control
keys are resolved by `vike-cli` exactly as they always are — process environment first, then
`/settings/secrets.env`. The runner opens no credential store of its own and holds no
venue key. `--settings-dir DIR` steers the child's project resolution when the working directory
would not answer.
Whatever cadence you pick, this notices things the morning after. What has to be noticed in seconds
— a dropped venue link, a stuck order, a breached ceiling — belongs to the daemon's own kill
switches and [dead-man's switch](/docs/trader/concepts/dead-mans-switch), and none of it is delegated
here. Read that page before treating a nightly agent review as coverage of anything.
## Read-only by default, and the default is hard to lose [#read-only-by-default-and-the-default-is-hard-to-lose]
A scheduled agent that can place an order is a different product from one that can read. This is
the second unless you go out of your way, and three separate mechanisms hold it there rather than
one:
1. **There is no `--profile` flag on this binary.** The scope is a boolean — `--allow-writes`, off
— which `crates/vike-agent-eval/src/unattended.rs`'s `profile_for` turns into the server's
`read-only` or `full` ring. A flag that parses a *name* has a wrong-value branch, and a
wrong-value branch that falls back instead of refusing is how a ring silently widens. A boolean
has no unknown value.
2. **The profile is passed explicitly on every launch.** The MCP server's own default is `full` —
deliberately, so that an absent flag there is the surface that shipped before profiles existed —
so a runner that merely *omitted* it would arm every write tool. `mcp_argv`, in the same file,
never omits it.
3. **The roster the server advertised back is cross-checked before the model sees it.**
`refuse_write_tools` reads each advertised tool's own `destructiveHint` annotation and aborts —
outcome `refused`, exit status 4, nothing driven — if a write tool is on it. An **empty** roster
is refused the same way: a session that could do nothing would otherwise "pass" a check about
what it was denied. Asking for a ring and *having* one are different facts, and this is what
turns the first into the second.
Widening is one flag and it is loud: `--allow-writes` prints a banner to stderr naming the node it
is about to be able to trade on, and every record stamps the flag and the profile, so a run that
*could* have written stays distinguishable from one that could not.
The [preview gate](/docs/ai/trader/trading) is untouched underneath all of it. Even with
`--allow-writes`, a write executes only against a `preview_token` the same server minted for the
same command — so an unattended agent that decides to trade still makes two calls, and both are in
the transcript below.
## The record [#the-record]
Two halves in the one directory `--record-dir` names, and only the second is new:
| file | written by | what it holds |
| ------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mcp-YYYY-MM.jsonl` | the MCP server | one line per `tools/call` — the tool, whether it is a write, the redacted arguments, the verdict **including every refusal**, the preview token's identity. The runner passes `--trace-dir`, so this is the same writer, caps and redaction table [`--trace`](/docs/trader/reference/cli/mcp) gives an interactive session (`crates/vike-cli/src/cmd/mcp_trace.rs`) |
| `run--.json` | `vike-agent-run` | the per-run header a per-call stream cannot carry: when, which task and prompt, which driver, how many model turns and tool calls, what the agent concluded, and the outcome |
| `run--.mcp.err` | the server's stderr | that run's server diagnostics, named as a sibling so the pairing shows in a directory listing. Not part of the record |
**Every run leaves one, on every path.** `run` returns a summary and never an error precisely so
that the caller has no branch on which a session happened and nothing was written down. A per-run
document is its own file rather than a line appended to a shared one: one record is produced at the
end of one run, so opening it `create_new` makes atomicity structural instead of bought with the
lock the per-call writer needs. Nothing here ever opens an existing record for writing, rewrites
one, or deletes one.
```sh
# every run, newest last
jq -r '[.started_utc, .task, .outcome, .conclusion] | @tsv' /run-*.json
# only the nights something went wrong
jq -r 'select(.outcome != "ok") | [.started_utc, .outcome, .detail] | @tsv' /run-*.json
```
`write_tools_offered` in a record is the evidence the ring held: it is what the *server* advertised,
not what this process asked for.
The writer is handed the model credential values this process holds and walks every string of the
document replacing them with ``, so a conclusion in which a model echoed its own token
back cannot land on disk. That covers the secrets this process has. A bare high-entropy string a
model invented is not detectable here, any more than it is in the per-call transcript — the same
residual, in the same terms.
## Outcomes, and what a non-zero status means [#outcomes-and-what-a-non-zero-status-means]
| outcome | exit | meaning |
| --------------- | :--: | --------------------------------------------------------------------------------- |
| `ok` | 0 | the agent answered within its budget |
| `failed` | 1 | the run could not be stood up, or the driver failed |
| *(usage error)* | 2 | a bad argument, or a model credential that is unset — never a silent skip |
| `timeout` | 3 | the wall-clock budget was reached; the session was ended and recorded |
| `refused` | 4 | the server advertised a roster the requested ring forbids. **Nothing was driven** |
Every non-zero status puts a `Type=oneshot` unit into the FAILED state, which is what an
`OnFailure=` notifier keys on.
**A run cannot hang.** Three bounds compose and none is this binary's invention: every JSON-RPC
request is bounded by the MCP client's response timeout, every model turn by its driver, and the
whole run by `--budget` (default 900s), checked at every tool call and re-checked when the driver
returns. The overshoot is stated rather than assumed away — the budget is checked *between* waits,
never inside one, so a run can exceed it by at most one model turn already in flight, and by zero
under `--driver claude-cli`, whose own kill the runner sets to the same instant. What cannot happen
is an unbounded wait, and what is recorded either way is a `timeout`.
## Scheduling is the operating system's job [#scheduling-is-the-operating-systems-job]
There is no cron expression in the binary, no daemon mode, no catch-up and no retry: a scheduler
here would be a second place the cadence is written down, and systemd already does missed firings,
jitter and failure notification. `deploy/vike-agent-run@.service` and
`deploy/vike-agent-run@.timer` are templates whose instance name is the task, installed the way
[the daemon's own unit](/docs/trader/getting-started/install-linux) is:
```sh
R=/your/project/root
sed "s,/srv/vike-agent-run,$R,g" deploy/vike-agent-run@.service > /tmp/u.service
grep -n /srv/vike-agent-run /tmp/u.service # must print NOTHING
$EDITOR /tmp/u.service # …then set --node to your daemon's address
sudo install -Dm644 /tmp/u.service /etc/systemd/system/vike-agent-run@.service
sudo install -Dm644 deploy/vike-agent-run@.timer /etc/systemd/system/vike-agent-run@.timer
sudo systemctl daemon-reload
sudo systemctl enable --now vike-agent-run@node-review.timer
```
Read the unit's header before installing it. Three things there are not boilerplate: the write
grant is `/settings/state` and must stay that narrow — anything writable by the unit must
be data, never the settings that configure the trading daemon beside it; `ExecStartPre` runs
`vike-cli config check`, so a settings tree that resolves to nothing fails the firing instead of
recording a night of refusals that look like an outage; and the sandbox was measured against Rust
binaries, which is why the shipped `ExecStart` names `--driver api` rather than taking the
binary's default implicitly.
The timer defaults to 05:40 UTC with a five-minute spread, and **omits `Persistent=`
deliberately** — the one line where it differs from every other unit in that directory. Replaying a
firing missed while the box was down would spend a model to file a record, stamped today, about a
moment that has passed. A missed review is a review that did not happen, and the honest record of
it is its absence.
cron and Windows Task Scheduler work the same way; the binary does not care what started it. The
model credential has to reach the process environment in every one of them.
## Tasks and drivers [#tasks-and-drivers]
`vike-agent-run tasks` prints the built-in prompts — `node-review` and `risk-review` today.
Deliberately few: a long list of canned prompts rots without anybody noticing, because nothing can
gate whether a prompt is still a good one. `--prompt TEXT` and `--prompt-file PATH` cover everything
else, and the record names whichever you used.
| `--driver` | what it is | what it costs |
| ------------ | --------------------------------------------------------------------------------------------------- | ------------------------ |
| `api` | the Anthropic Messages API in an ordinary agent loop — the default, and what the shipped unit names | API credits |
| `claude-cli` | the locally-installed Claude Code CLI in headless mode, driving the same surface | a subscription allowance |
| `scripted` | a canned probe — one `node_snapshot`, then an answer. No model at all | nothing |
`--driver scripted` is a real operator tool rather than only a test fixture: scheduled, it is a
daily proof that the agent surface still stands up against your node, for no model spend.
The model credential is read from the process environment by the binary and nowhere else —
`ANTHROPIC_API_KEY` for `api`, `CLAUDE_CODE_OAUTH_TOKEN` for `claude-cli`. It is not a venue
credential and nothing looks for it in the credential store; the runner **removes both names from
the environment of the `vike-cli mcp` server it spawns**, so the token never sits inside the process
holding the node connection. An absent one is a refusal naming the variable, not a skip: a
scheduled run that quietly measured nothing and exited 0 would go unnoticed until somebody wondered
why the record directory was empty.
## What it deliberately does not do [#what-it-deliberately-does-not-do]
* **No cloud, no account, no hosted agent.** Nothing here phones anywhere except the model endpoint
you chose.
* **No remote control.** The runner opens no listening socket. Nothing can ask it to run; it runs
when your operating system starts it.
* **No trading by default,** and never without `--allow-writes` on the command line of whatever
started it.
* **No grading.** There is no expected answer to "is anything wrong with my node this morning", so
the record is evidence for you to judge, never a verdict.
* **No default for `--record-dir`.** It is required, because every default available to it would be
a second project walk that could answer with a different project than the credentials came from.
One line in a unit file, one argument at a prompt.
## Residuals [#residuals]
* **It is not a release asset yet.** Build and install it beside `vike-cli` — the unit fails with
`status=203/EXEC` without both, and it finds `vike-cli` beside its own executable, one directory
up, or wherever `--vike-cli PATH` names.
* **Nothing prunes the per-run records.** The per-call transcript prunes its monthly files; the
per-run documents accumulate one small file per firing. A daily task is \~365 files a year — fine
for years, and still a bound nobody armed.
* **The `claude-cli` driver has not been measured under the shipped sandbox.** That driver spawns a
JIT runtime that reads its configuration out of your home directory, and two of the unit's
hardening lines are hostile to it. The unit's header says which, and says so rather than shipping
a guess.
# Downloading (/docs/data/archive/downloading)
There is one rule for the bulk archive:
Downloading a 7 GB partition is **one** HTTP request. Reading a few columns out of that same file
over HTTP is **thousands**, because every Range request the reader issues is a separate request
over a connection capped at 50 MB/s. Neither spends your hourly budget — archive downloads stopped
counting against it on 2026-09-02 ([Rate limits](/docs/data/rate-limits)) — but only one of them is
fast.
## Why, exactly [#why-exactly]
Every request to a gated `/archive/` path is authorised on its own: the credential is validated and
one usage row is written under the `archive_download` label. That happens **per HTTP request**.
The hourly counter is **not** checked, so no number of archive requests can rate-limit you; the row
is there so your usage view shows what you downloaded.
A Parquet reader working over HTTP fetches the footer, reads the metadata, then fetches each column
chunk of each row group it needs as its own `Range:` request. Columns multiply by row groups, and a
large partition has many of both.
**Measured on the live archive:** one **1.26 GB** partition, read remotely, produced **20,237**
requests — more than an entire hour's ceiling, for a single file. Before 2026-09-02 every one of
them counted against that ceiling, and of **573** rate-limit rejections in the platform's history,
**572** were archive downloads. That is why the archive stopped spending the budget. It is not a
reason to read remotely: each of those requests is still a round trip.
The contrast is the whole of this page:
| Approach | Requests for one 1.26 GB partition |
| -------------------------------------------- | ---------------------------------- |
| `curl` the whole file, parse locally | **1** |
| Point `pyarrow`/`duckdb`/`polars` at the URL | **\~20,000** |
A complete pull of every date and every stream in the archive is roughly **120 whole-file
requests** for hundreds of gigabytes.
## Do this [#do-this]
### curl [#curl]
```sh
# 1. The manifest is open — no key, and it lists every partition with size and row count.
curl -s https://data.vike.io/archive/manifest.json -o manifest.json
# 2. One whole file, one request.
curl -H "X-API-Key: $VIKE_API_KEY" \
-o polymarket-trades-2026-09-01.parquet \
"https://data.vike.io/archive/venue=polymarket/date=2026-09-01/trades.parquet"
# 3. A multi-gigabyte file, resumable. `-C -` sends one Range request to pick up where the
# partial file stopped, so each resume is one more request -- not thousands.
curl -H "X-API-Key: $VIKE_API_KEY" -C - --retry 5 --retry-delay 10 \
-o polymarket-book-2026-09-01.parquet \
"https://data.vike.io/archive/venue=polymarket/date=2026-09-01/book_events.parquet"
```
Use `-o` with an explicit name, not `-O`. The last path segment is `trades.parquet` for every
date, so `-O` overwrites the previous day's file with the next one.
A bounded fan-out is fine — it is one request per file either way:
```sh
jq -r '.dates[].date' manifest.json | \
xargs -P 4 -I{} curl -sS -H "X-API-Key: $VIKE_API_KEY" \
-o "l1-{}.parquet" \
"https://data.vike.io/archive/venue=polymarket/date={}/l1_quotes.parquet"
```
### Python [#python]
```python
import requests
import pyarrow.parquet as pq
KEY = "vk_..."
URL = "https://data.vike.io/archive/venue=polymarket/date=2026-09-01/trades.parquet"
LOCAL = "polymarket-trades-2026-09-01.parquet"
# Download: ONE request, streamed to disk so a multi-GB file never has to fit in memory.
with requests.get(URL, headers={"X-API-Key": KEY}, stream=True, timeout=300) as r:
r.raise_for_status()
with open(LOCAL, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 20):
f.write(chunk)
# Parse: LOCAL path, so column pruning and predicate pushdown cost nothing at all.
table = pq.read_table(LOCAL, columns=["ts", "token_id", "price", "size", "side"])
```
Verify what landed against the manifest's own `bytes` for that partition before parsing — a
truncated download is a valid-looking file until the footer read fails.
### DuckDB, polars, pandas [#duckdb-polars-pandas]
Same shape: fetch first, then open the local path.
```sql
-- Right: the files are already on disk.
SELECT token_id, count(*) FROM read_parquet('polymarket-trades-*.parquet') GROUP BY 1;
```
## Do not do this [#do-not-do-this]
```python
# WRONG. Every column chunk of every row group becomes its own Range request over the network.
table = pq.read_table("https://data.vike.io/archive/venue=polymarket/date=2026-09-01/book_events.parquet")
```
```sql
-- WRONG, for the same reason: httpfs turns one query into thousands of requests.
INSTALL httpfs; LOAD httpfs;
SELECT * FROM read_parquet('https://data.vike.io/archive/venue=polymarket/date=.../book_events.parquet');
```
The files do advertise `Accept-Ranges: bytes`, and the manifest sets `range_requests: true`. That
is there so an interrupted **download** can resume — not so a query engine can page a remote file.
## Fetch less by choosing a smaller file [#fetch-less-by-choosing-a-smaller-file]
The supported way to narrow a pull is to pick a narrower **file**, never a byte range inside a
wide one:
* **Only need the tape?** Take `trades.parquet` — around 14 MB a day, against several gigabytes of
full L2.
* **Only need top-of-book?** Take `l1_quotes.parquet`, a few hundred megabytes a day.
* **Only need one crypto up/down family?** Use the family layout —
`venue=polymarket/asset=btc/tenor=5m/date=YYYY-MM-DD/…` — where a day is tens to hundreds of
megabytes instead of gigabytes. The layouts and their caveats are on
[Bulk archive](/docs/data/archive#two-layouts).
* **Only need one day?** Partitions are per UTC day already; ask the manifest which dates exist
rather than probing for them.
Every one of those is still one request per file.
## If you get a 429 or a 401 [#if-you-get-a-429-or-a-401]
The archive never returns a rate-limit **429**: an `/archive/` request is not checked against the
hourly counter. A missing or invalid credential is a **401**. The budget the same key's `/v1` and
MCP calls share is unaffected by downloads, and those calls are what a 429 comes from; see
[Rate limits](/docs/data/rate-limits) and [Errors](/docs/data/errors).
# Bulk archive (/docs/data/archive)
The bulk archive is research-scale Polymarket history as Parquet, served as static files from
`https://data.vike.io/archive/`. It is the whole Polymarket universe — crypto up/down, sports,
politics — not a curated subset, and it is where **full L2 book history** is published in bulk:
every book event as recorded, rather than a window or an aggregate over one.
Archive downloads do not spend your hourly budget, but a Parquet reader pointed at an archive URL
issues thousands of Range requests for one file, each a round trip over a bandwidth-capped
connection. Download whole files and read them locally —
[Downloading](/docs/data/archive/downloading) is the page that shows how.
## The three streams [#the-three-streams]
Every partition holds up to three files, one per stream:
| Stream | What it is |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `book_events.parquet` | **Full L2.** Book snapshots, `price_change` deltas, trade prints, tick-size changes and status markers, in sequence. |
| `trades.parquet` | The **taker tape** alone — the trade prints, without the book. |
| `l1_quotes.parquet` | **Derived top-of-book**: bid, ask and both sizes, one row per change. |
`trades` and `l1_quotes` are derivable from `book_events` and are published separately because
they are two to three orders of magnitude smaller. If top-of-book is all you need, take
`l1_quotes` — a day of it is hundreds of megabytes against several gigabytes of full L2.
## Two layouts [#two-layouts]
The archive is Hive-partitioned, and there are two partitionings of the same data. The manifest
carries both, under `partitioning` and `family_partitioning`.
**By date** — the primary layout, one UTC day per partition, every market in it:
```
/archive/venue=polymarket/date=YYYY-MM-DD/book_events.parquet
/archive/venue=polymarket/date=YYYY-MM-DD/trades.parquet
/archive/venue=polymarket/date=YYYY-MM-DD/l1_quotes.parquet
```
**By family** — an additive alternative that groups the crypto up/down families (asset × tenor)
into their own much smaller files, so a single-family customer does not download a whole
token-sorted day:
```
/archive/venue=polymarket/asset=btc/tenor=5m/date=YYYY-MM-DD/book_events.parquet
```
As of this writing the manifest lists `asset` values `btc, eth, sol, xrp, doge, bnb, hype` and
`other`, with `tenor` values `5m, 15m, 4h` and `other`. **`asset=other/tenor=other` is the
catch-all** for every market that is not a recognised up/down family — sports, politics, one-off
events — so nothing is silently dropped by choosing this layout.
Two caveats. The manifest states the first outright: **a family with zero rows in a stream on a
given day has no file for that stream**, so a missing object is a legitimate answer, not an
error. The second is visible in the manifest's own entries: the family layout is *additive*, and
it does not currently cover every date the `date=` layout does. Enumerate `families[]` rather
than assuming a date exists in both.
**Choosing a narrower file is the supported way to fetch less data.** Reading a byte range out of
a wide one is not — see [Downloading](/docs/data/archive/downloading).
## The manifest is the authority [#the-manifest-is-the-authority]
```sh
curl -s https://data.vike.io/archive/manifest.json -o manifest.json
```
`manifest.json` needs **no key**. It is regenerated daily and carries `generated_at`, both
partitioning patterns, the inline column schemas, the auth scheme, and one entry per partition
with byte size and row count. Anything below is a snapshot; the manifest is what is true today.
As of `generated_at` **2026-09-02T00:38:00Z**:
| | |
| ----------------------------------- | --------------------------------------------------------------- |
| Coverage (`date=` layout) | 39 consecutive UTC days, 2026-07-25 through 2026-09-01, no gaps |
| Coverage (`asset=`/`tenor=` layout) | 29 of those days, 2026-07-26 through 2026-09-01 |
| Total size | \~248 GB across all streams and dates |
| `book_events` | 4.2–7.7 GB per day (median 5.8 GB); median \~364M rows/day |
| `l1_quotes` | 328–669 MB per day (median 464 MB); median \~42.8M rows/day |
| `trades` | 12–19 MB per day (median 14 MB); median \~1.14M rows/day |
| Largest family file | `asset=other/tenor=other` book events, \~2.9 GB/day median |
| Smallest family files | the `4h` tenors, 1.6–11.5 MB/day median |
The three oldest partitions carry byte sizes but a `null` row count — treat `rows` as optional
when parsing, not as a guaranteed field.
## Column schemas [#column-schemas]
The manifest carries these inline, under `schema`. They are spelled in the source store's own type
vocabulary — `Decimal(p,s)`, `Enum8(...)`, `LowCardinality(String)` — not in Parquet's, so what
your reader hands back does not always match the spelling character for character. The two places
where that matters are noted below.
### `book_events` [#book_events]
| Column | Type | Notes |
| ---------------------- | ------------------------------------------------------------------ | --------------------------------------------------------- |
| `token_id` | `String` | The outcome token. A market has one per outcome. |
| `condition_id` | `String` | The market. `0x`-prefixed. |
| `ts` | `Int64` | Event time, epoch **milliseconds** UTC, as published. |
| `local_ts` | `Int64` | Recorder receive time, epoch ms. Never earlier than `ts`. |
| `seq` | `UInt64` | Feed sequence number. |
| `event_type` | `Enum8('book','price_change','trade','tick_size_change','status')` | |
| `is_snapshot` | `UInt8` | `1` on a full-book row, `0` on a delta. |
| `side` | `Enum8('none','buy','sell')` | |
| `price`, `size` | `Decimal(9,4)`, `Decimal(18,6)` | The changed level, on a delta. |
| `best_bid`, `best_ask` | `Decimal(9,4)` | Present in the schema; use `l1_quotes` for top-of-book. |
| `bids`, `asks` | `String` | JSON arrays of `[price, size]` pairs. |
| `tick_size` | `Decimal(9,4)` | |
| `status` | `LowCardinality(String)` | Empty on ordinary rows. |
Two things about that table are easy to get wrong, both checked against the current free sample:
* **`bids`/`asks` carry only the side that changed.** On a `book` snapshot row both hold the whole
ladder. On a `price_change` only the column matching `side` is populated and the other is an
empty string — `side = buy` fills `bids`, `side = sell` fills `asks`. `trade` and `status` rows
populate neither.
* **The two `Enum8` columns arrive as bytes, not strings.** They are written as the Parquet ENUM
logical type, so `pyarrow` types them `binary` and you read `b'price_change'`, not
`'price_change'`. Decode before comparing, or the filter silently matches nothing.
### `trades` [#trades]
`token_id`, `condition_id` (`String`), `ts`, `local_ts` (`Int64`), `seq` (`UInt64`), `price`,
`size` (`Float64`), `side` (`String` — `buy` or `sell`).
Note the type differences from `book_events`: `trades` carries `price`/`size` as `Float64` and
`side` as a plain `String` — not a `Decimal` and an `Enum8` — so the decoding note above does not
apply here. `seq` is `0` on every row of the current sample; order on `ts`, not on `seq`. Do not
assume one schema across streams.
### `l1_quotes` [#l1_quotes]
`token_id`, `condition_id` (`String`), `ts`, `local_ts` (`Int64`), `bid`, `ask` (`Decimal(9,4)`),
`bid_size`, `ask_size` (`Decimal(18,6)`).
### Two properties worth relying on [#two-properties-worth-relying-on]
**Files are token-sorted.** Rows for one `token_id` are contiguous and ascend by `ts` within that
run. That is what makes the family layout worth having, and it means a single-token extraction is
a contiguous scan once the file is local.
**Recorder gaps are marked, not hidden.** `status` carries `gap_start` and `live_resume` markers
where the recorder lost and regained the feed. Filter on them before treating a stretch of the
book as continuous — silence in a full-L2 stream is otherwise indistinguishable from a quiet
market.
## Free samples [#free-samples]
`/archive/samples/` needs **no key** and is a real slice of the full archive, not a synthetic or
truncated demo: one complete crypto up/down 5-minute market — the busiest of its kind that day by
`book_events` row count — with all three streams for **both** outcome tokens across the market's
entire lifecycle, open through resolution.
```sh
curl -s -O https://data.vike.io/archive/samples/README.txt
curl -s -O https://data.vike.io/archive/samples/book_events.parquet
curl -s -O https://data.vike.io/archive/samples/trades.parquet
curl -s -O https://data.vike.io/archive/samples/l1_quotes.parquet
```
The samples are regenerated daily from a recent real day, so the market slug, `condition_id` and
sizes change; `README.txt` names the market and date of the current one. Same column schema as the
full archive, at a few megabytes rather than a few gigabytes — the right place to write and debug
a parser before you create a key.
## Authentication [#authentication]
Everything under `/archive/` except `manifest.json` and `/archive/samples/` needs a credential:
```sh
curl -H "X-API-Key: $VIKE_API_KEY" -o trades-2026-09-01.parquet \
"https://data.vike.io/archive/venue=polymarket/date=2026-09-01/trades.parquet"
```
The manifest names the header as `X-API-Key`; the REST API's spec spells it `X-API-KEY`. HTTP
header names are case-insensitive, so these are the same header and the same key —
[the same one](/docs/data/reference#authenticate) that gates `data.vike.io/v1` and MCP. Unlike those,
an archive download does **not** spend [the hourly budget](/docs/data/rate-limits); it is recorded in
your usage view and nothing more. An unauthenticated request to a gated object is a **401**, not a
404: the path resolved, the credential did not.
Gated responses carry `Cache-Control: private, no-store`, so the CDN never caches an
authenticated file — caching one would bypass both the gate and the usage record.
# Trader changelog (/docs/changelog/trader)
Seventeen versions have shipped, from **0.1.0 on 2026-07-08** to **0.1.16 on 2026-09-02**. This
page is the user-facing record: per version, what a user of the platform gets, what changes
behaviour on an existing install, and the numbers behind a fix where a number exists.
Versioning is [SemVer](https://semver.org/), and the platform is **pre-1.0 — a patch bump may
include a breaking change**. Two of the seventeen change behaviour on a box that is already
running, and one moves a directory: [0.1.1](#011--2026-08-18) (settings, credentials and state
move into one project folder), [0.1.9](#019--2026-08-24) (credential presence stops arming a
venue) and [0.1.15](#0115--2026-08-31) (`/data` becomes `/market_data`). Read
those three before upgrading across them.
Each version is a git tag. The release pipeline builds every binary from that tagged commit on a
Linux runner, embeds an SBOM into each one with `cargo auditable`, and publishes a `SHA256SUMS`
manifest covering every asset — binaries, the Dukascopy sidecar jar and the pinned LightGBM tool
alike. Since 0.1.15 the container images are built *from those published assets*, verified against
that same manifest before anything is copied in, so "the image is the release" is checked rather
than assumed. [Install with Docker](/docs/trader/getting-started/install-docker) is the current install
path.
The platform's source repository is private, so there are no public release-note pages to link to.
Each entry below was written from the project's own curated changelog and from the merged
pull requests in each tag's span — the same two sources a maintainer would re-check. Measurements
quoted here are taken from those records rather than restated from memory.
## 0.1.16 — 2026-09-02 [#0116--2026-09-02]
**The platform got a licence.** Until this version the tree carried none at all — signed trading
binaries were shipping to end users under no terms. It is now **FSL-1.1-ALv2** (`LICENSE.md`):
source-available today, with each version irrevocably becoming Apache-2.0 on the second
anniversary of its own release, and paid support and consulting explicitly permitted — the reason
it was chosen over Commons Clause, which bans exactly that.
**One executable instead of seven.** The new `vike` binary carries every headless tool —
`vike-cli`, `vike-tradehub`, `backtest`, `vike-datahub`, `vike-recorder`, `vike-study`,
`tearsheet` — dispatched by `argv[0]` (an installed symlink) or by `vike `. Measured: the
three DataFusion-linked binaries were 128 + 106 + 107 MB,
each carrying its own static copy of the same query engine; the multicall is **128 MB**, the size
of the largest tool it replaces. Every old binary name is kept as a thin shim, so an existing
`ExecStart=` or installed path still resolves.
**The container image became a channel people can actually pull.** It now carries the whole
headless product — one binary and seven symlinks, **128 MB against roughly 500** — and is pushed to
Docker Hub (`vikeio/vike-tradehub`) and GHCR (`ghcr.io/vike-io/vike-tradehub`). There is no
floating `latest`: each image is tagged with its release version alone.
`vike-cli secrets template` prints a starter credential grid — every roster venue × SIM/DEMO/LIVE ×
the credential suffixes plus that venue's attribution keys, the legacy `MAINNET` tier and the
bespoke per-venue shapes deliberately absent — with empty values, derived from the venue roster so
a new venue appears the day its bridge lands. It prints to **stdout only**; a `--out` flag was
deliberately not added, because the moment the command owns a path it can truncate a live store.
⚠ **FXCM still does not trade from the published image.** The ForexConnect runtime libraries are
*in* the image, but the multicall's release build never enables `fxcm`: enabling it links
`libForexConnect`, and the resulting executable dies at exec wherever that SDK is absent — which
would break every non-FXCM user of the same binary. The separate `vike-tradehub-fxcm` release
asset remains the FXCM path.
Also fixed: three of the eight binaries the image roster named had never built at all, because the
roster named a library package and a file name rather than real cargo bin targets — and every text
check agreed with it.
## 0.1.15 — 2026-08-31 [#0115--2026-08-31]
⚠ **The Linux desktop GUI had never actually run.** The GUI loaded every font face from a
`C:\Windows\Fonts\…` path, so off Windows the named families were never bound and the app panicked
on the title bar in frame one — while the release had been publishing a Linux GUI asset since
0.1.14. Compilation checks and the PNG-export lane could not see it: they render the chart and
Studio *examples*, not this binary. Found by running it in a container.
**Windows GUI assets.** `vike-app.exe` and `vike-app-thin.exe` are now cross-built on the Linux
release runner and published. ⚠ They are `x86_64-pc-windows-gnu`, not `-msvc` — the msvc target
needs the MSVC linker and Windows SDK, which no Linux runner has. `vike-app-thin` (Linux,
observe-only) also joined, at 51 MB against the fat build's 152 MB.
**Two container images**, `vike-tradehub` (the daemon) and `vike-thin` (the observe-only GUI), both
built from the assets the release already published and verified against its own `SHA256SUMS`.
⚠ **`/data` is now `/market_data`.** It sat beside `/user_data` and the
pair read as one concept split in half. The one configuration that moves is a deployment naming no
store root — and **a store does not merge**: the old one is simply no longer read, and a run
reports zero rows rather than failing, so check where your store resolved before assuming.
**`vike-tradehub` gained the `ibkr` feature.** Before this the GUI could mount IBKR live while the
headless daemon fell through to paper **in silence** — the daemon crate had never carried the
feature the GUI forwarded.
Fixed: an absent observe key was signed with `""` and the node answered `bad mac`, so "no key"
reached the operator as "wrong key". And a `--observe` build warned that "this chart will stay
empty" while the chart filled from the node protocol — measured while it logged on repeat against
five hours of live candles.
## 0.1.14 — 2026-08-29 [#0114--2026-08-29]
⚠ **A limit price sitting exactly on the venue's tick grid could reach the wire a whole tick low.**
Rounding a price to `n` steps computes `n · fl(step)`; where a step's f64 image sits below its
decimal value, that product lands one ULP under the grid point and the wire quantizer's truncation
then drops a whole step — `5 · fl(1e-6)` reached the venue as `"0.000004"`. Measured against live
public instrument
grids: **477 order-placing markets carry an exposed tick** (binance spot 96, binance perp 124,
bybit linear 124, okx swap 47, aster 85, deribit 1), and across the 96 exposed binance-spot symbols
one tick is worth a **mean of 8.45 bps and a max of 51.8 bps (ATOMBTC), on about 30% of prices**.
Truncation biases every limit price toward zero: a sell gives up value and can cross into a taker
fee, a buy loses queue position. The paper exchange never truncated, so live and backtest diverged
by a full tick on exactly those markets. The quantity axis was nearly harmless — the first loss is
at `n = 5`, so at least four steps always reached the wire.
⚠ **The market maker quoted different prices on Windows and Linux.** Seven of nine measured rows in
`vike-mm` differed bit-for-bit between Windows/MSVC and Linux/glibc, including the
Avellaneda-Stoikov optimal half-spread and the LMSR reservation price; the two rows that agreed are
the control that makes it a finding rather than a broken harness. Cause: the platform's own `libm`
for `exp`, `ln` and `powf`. Cure: the `libm` crate at those sites, so a quote is the same number on
both boxes. The geometric order ladder had the same divergence in `ratio.powi(k)` and was converted
with it.
**The desktop GUI became a release asset** — `vike-app-fat` for Linux x86-64, with its SBOM
embedded and its line in `SHA256SUMS`. Obtaining the GUI used to mean compiling the workspace.
**Thirteen of the fourteen venues can now express a second account**, and the credential editor can
create one. Also fixed: 44,840 leaked temporary directories, and four venues whose credential dots
and forms read key names no bridge reads.
## 0.1.13 — 2026-08-25 [#0113--2026-08-25]
Two fixes, both found on a live box after the previous deploy. **A reconcile pass that changed
nothing logged anyway**, once per pass carrying alerts — about 1,440 lines a day per venue, and
wholly redundant when nothing moved. A pass that folds events still logs every time, and a venue
with nothing held stays silent exactly as before.
**Deribit's post-reconnect history replay stopped forever on the first socket close, in silence.**
The resync transport connected once and nothing re-dialled it, so a venue-side close ended the
replay of order and trade history across reconnect gaps for the life of the process, with every
error swallowed. It was the last of three never-heals sockets in that bridge; the other two were
fixed in 0.1.11. An outage is now bracketed by one warning naming the consequence and one recovery
line, and the latch re-arms — a latch that fires only once makes a broken lane look healthier the
longer it stays broken.
## 0.1.12 — 2026-08-25 [#0112--2026-08-25]
**A venue can mount several accounts.** The mount now creates one engine per *active*
account — its per-account ceiling permits it, its credentials load, the venue's arm can address an
account at all, and no symbol collision refuses it. Account selection and the Data Manager's
Effective column are one function, so the screen cannot disagree with the mount, and a
default-account box is byte-identical (measured: one engine per roster venue, an empty live-venue
set, and a ledger record with no account key at all).
⚠ Only binance, bybit, okx and deribit consume the account-aware credential loader. A labelled
account is **refused** elsewhere rather than half-supported — arming one would otherwise have built
a live client on the first account's credentials.
Also: the live journal's p99.9 tail is gone rather than reduced — **37,150 → 3,342 ns**, an 11×
improvement over interleaved medians of eight repetitions, landing where the journal-less baseline
already sat.
## 0.1.11 — 2026-08-25 [#0111--2026-08-25]
**Security — a Rhai strategy, and a user indicator, could read any file on disk.** Fixed.
⚠ **IG's fill lane had never worked, for any account, demo or live.** The trade stream sent
`LS_cid: "vike-trader-rust"` where Lightstreamer's client-type licensing field must carry the
TLCP-2.1.0 mandated value, and IG answered `CONERR,71 License not valid for this Client type` on
every dial since the day it was written. The market-data lane always sent the correct value, which
is why feeds worked while fills did not — and why the split read as an account-permissions problem
for weeks. Proven on the wire against the demo gateway with one login and two client ids,
everything else held constant.
**A Deribit socket closing mid-submit reported an order rejected that the venue may have
accepted.** Only a response timeout was treated as ambiguous, so a mid-request close — the common
first error on a dying socket — synthesized a terminal reject for a possibly-live order, stranding
a phantom position. Resolution is now re-dial then re-query, never re-send. Separately, a Deribit
reconcile lane died on its first socket close and never recovered: 795 identical failures in a day
while the daemon reported healthy.
**Two log floods that could hide a real event.** A permanently refused IG stream wrote an error
line every second forever — 53,160 lines and 23 MB in one day from one loop, 99.5% of that box's
entire error volume. And a held reconcile divergence re-announced itself every interval with an
unbounded alert store: 396 warnings in a day for one bybit divergence, with a genuinely new one
indistinguishable among them. A held divergence is now a **state** — announced on entering and on
clearing, with a slow backlog summary and one stored row per distinct identity.
Added: perpetual funding premium is no longer discarded, and is recorded as `kind=perp_metrics`;
and a study can be run from the Studio GUI, either tier, with one results surface.
## 0.1.10 — 2026-08-24 [#0110--2026-08-24]
Small, and both entries are consequences of watching the previous release run. **A held reconcile
divergence now reaches the log**, not only an in-memory ring: the shipped daemon's control channel
is off, so `reconcile pass folded venue=bybit events=0 alerts=2` repeated every 60 seconds for
hours with nothing at WARN or above, and no way for anyone reading logs to learn *which* two
divergences were being held. The raise path now emits a warning carrying the divergence kind, venue
and detail; the refresh path stays silent on purpose, because a divergence held for a day would
otherwise re-raise 1,440 times. The second fix is a release-blocking test flake, not a product
change.
## 0.1.9 — 2026-08-24 [#019--2026-08-24]
⚠ **Breaking behaviour: credential presence stops arming a venue.** Per-venue arming is now a
setting — `policy.toml`'s `[venues]` table, `paper` / `demo` / `live`, defaulting to `paper` — and
it is read *above* the credential read, so a disarmed venue loads no credential, fetches no
instrument grid and opens no socket. The ceiling can only ever refuse; a `live` line arms nothing
by itself. Measured on a live box beforehand: a daemon profile that mounted **one** venue printed
**nine** live authenticated exec sessions, aster among them — a venue with no testnet, whose keys
reach the real account.
**A box with no `[venues]` table therefore mounts everything on paper**, whatever its credential
store holds. That is loud rather than silent: the daemon warns once at startup naming the file, the
key and every venue it refused, rather than refusing to start. The GUI's Data Manager gained the
matching screen — what is live, and why not.
Also in this version: the live **change journal** records what each venue was *asked* to be and
what it *became*; Polymarket answers its own geoblock **before** the mount instead of one 403 at a
time, and a cancel batch survives to the venue so the flatten reserve's bulk arm is no longer dead
code; the REST rate gate became FIFO, so a heavy caller is no longer starved by light ones inside
quota; and the first (additive, locally built) backend container image landed, with the project
folder mounted rather than baked in.
## 0.1.8 — 2026-08-22 [#018--2026-08-22]
⚠ **The two runtime tools became obtainable.** A release now attaches the Dukascopy JForex sidecar
jar and the pinned LightGBM binary — with the `PROVENANCE` file vike refuses to run without —
covered by the same `SHA256SUMS` as the binaries, and they are downloaded, verified and installed
into `/bin//`. Before this, a research run hard-failed on a fresh install because
nothing shipped LightGBM, and Dukascopy worked only because a 43 MB jar was committed into the
source tree.
⚠ **The jar is no longer committed, so an offline clone no longer gets one.** `JFOREX_BRIDGE_JAR`
names one outright, and the provisioning script's `--rebuild` still builds one from source. Its CI
gate became a *reproducibility* gate instead: the jar is built twice from a cleaned directory and
the two must be byte-identical, which is what lets anyone re-derive a published digest from the
tag.
Fixed: the startup preflight's clock budget was a flat constant sized against six venue reads, and
the roster outgrew it — 5,973 ms needed against 5,000 available, so eight venues reported their
clock "not read". It is now derived from how many venues are actually read, sized to absorb one
dead venue and still read the rest.
## 0.1.7 — 2026-08-22 [#017--2026-08-22]
⚠ **The startup preflight's clock budget was being spent by credential probes.** The two legs
interleave per venue, and a credential probe is a blocking authenticated read — so one unreachable
venue sat about 20 s on a TCP connect and cost alpaca, aster and hyperliquid their clock checks,
while the log blamed "an earlier venue's clock read" and sent operators to inspect rows that were
all fast and healthy. Each probe is now measured and the deadline pushed out by its cost, so the
budget bounds the clock leg and only the clock leg.
**OKX options were never in the catalog.** The listing was fetched as a bare `instType=OPTION`,
which the venue refuses with `400`/`50015`, while okx's advertised asset classes included options
and the picker received zero. Now fetched once per underlying: **okx 2,004 → 5,162 instruments**.
**Alpaca reported a transport failure as `HTTP 401`** — answering a network condition with a
credential rotation. A token mint that answered and refused is now separated from one that was
never reached. And the GUI can now be launched with no credentials in scope, so every venue stays
paper by construction: on a populated box the obvious "just look at a chart" run could not start at
all, because a live mount refuses without an operator risk budget.
## 0.1.6 — 2026-08-19 [#016--2026-08-19]
**Five more venues joined the daemon's live-wired set** — alpaca, ctrader, oanda, deribit and ig —
each mounting a credentialed market-data lane the way binance and friends already did. Deribit,
oanda and ig also gained their first *live* market-data adapters: oanda's chunked-HTTP pricing
stream, and ig's Lightstreamer TLCP spoken raw over the shared transport stack. ⚠ IG serves no
depth, which its adapter says outright rather than reporting an empty book.
**The datahub authenticates.** It had served history reads, backtest compute over client-supplied
Rhai and — in a backfill-serve build — writes into the store, to anything that could reach the
socket. It now speaks the node handshake with scoped reads and writes; **keys absent serves exactly
as before**, so an existing single-user install is unchanged.
**Settings hot-reload for the classified-safe subset**: the console and file log levels apply on a
running daemon, every other key answers `restart_required`, and `policy.toml` is *structurally*
excluded, so no future key can make a risk ceiling hot-appliable. Runtime strategy mounts now
survive a restart — a mount is remembered as daemon state, and only an explicit unmount forgets
one. `vike-cli strategy-status` asks a running node what it is running (human table, or the wire
payload verbatim under `--json`); asking used to mean compiling a throwaway probe.
A cluster of fixes with one shape — **a capability gap was answering as data**: a history store
that cannot serve depth now refuses instead of returning an empty read, a failed Studio scan
stopped rendering as an empty store, oanda's live tier refuses instead of vanishing, and an FXCM
login that *failed* reddens its smoke test instead of being reported green.
## 0.1.5 — 2026-08-19 [#015--2026-08-19]
**Aster joins the daemon's live-wired venues** — ⚠ capability only, and the mainnet warning travels
with it: no aster testnet credentials exist, so an armed aster lane reaches the real account.
**Backends are managed from the GUI** — add, edit and delete backend records in place, with
key-name hygiene on the credential names each record points at. **Settings became writable over the
wire**, behind the control scope and a typed confirmation: the would-be file is validated with the
same loader that will read it back, edited comment-preservingly (untouched lines stay untouched
bytes) and landed atomically. `policy.toml` is special-cased — a risk ceiling is not a remote edit.
## 0.1.4 — 2026-08-18 [#014--2026-08-18]
**One daemon, N strategies, N venues.** Strategies mount and unmount at runtime off the fold, and
two new wire verbs — a read-only `StrategyStatus` and a control-scoped `UpdateParams` — let you
inspect and retune a mounted strategy without restarting the daemon.
**A third GUI mode**: observe a backend while taking venue market data directly rather than through
that backend. **Backfill-on-demand over the datahub wire** — the backend fetches a bounded kline
range once, writes it through to the store, and replies only after the served handle reads it back;
it is capability-negotiated, so a client refuses locally against a server that does not advertise
it, and remote-mode chart gaps backfill through the same path.
Reconciliation gained **EXTERNAL-tagged divergences and an opt-in hold-first policy**
(`external-quarantine`), so a foreign account's activity needs an operator claim instead of being
folded blind. Hyperliquid and aster grew **feeds-only builds**: the market-data plane compiles
without the order signer.
## 0.1.3 — 2026-08-18 [#013--2026-08-18]
**Rhai strategies mount live.** `[strategy] rhai = ""` in a daemon profile compiles the
script through the same engine builder the backtest arm uses and mounts it on the same paper or
live core, with the script's audit hash logged at mount.
The GUI gained a **persisted backend registry** (records, key resolution, and the control arming
gate) and **runtime backend switching** — one active backend, total state clear on switch — with
both status surfaces naming the connected daemon and whether it is LIVE or paper. Studio moved onto
the `HistStore` trait, so it can browse a served store rather than only a local one.
## 0.1.2 — 2026-08-18 [#012--2026-08-18]
The split-plane safety trio. **`LiveLock` — one live process per venue account**, so two live
traders can no longer silently rewrite each other's positions through reconciliation. **A Windows
console-ctrl graceful stop**: Ctrl-C and window-close now run the same bounded teardown as the unix
SIGTERM arm. And **the observe snapshot names its daemon** — profile name, mounted strategy,
paper-vs-live and build identity — so a client can tell which node it is looking at.
## 0.1.1 — 2026-08-18 [#011--2026-08-18]
The largest release by a wide margin: **1,062 merged pull requests over six weeks**, and the one
that reshapes an existing install.
⚠ **Breaking — settings unification.** Every setting the typed settings model reads from a file,
every credential and the state the program writes for itself now live in one project-owned
directory:
```text
/settings/policy.toml config.toml preferences.toml flags.toml
/settings/secrets.env every API key, every venue
/settings/state/… program-written files
```
Credentials moved from `/.env`. Absent credentials **are** the live gate, so a leftover
`.env` used to mean every venue silently on paper with nothing in any log; an absent store with a
`.env` beside it now warns on every credential read, naming both paths — a finding, never a
refusal, because `.env` is also a legitimate systemd `EnvironmentFile`, and nothing reads, prints
or moves its contents. `` is resolved at runtime by walking up for a project marker;
`VIKE_SETTINGS_DIR` names it outright and skips the walk, which is what a deployment should set.
⚠ **Three environment variables are refused at startup, not ignored.** `VIKE_MAX_ORDER_NOTIONAL`
and `VIKE_TRADEHUB_MAX_ORDER_NOTIONAL` became `policy.toml`'s `max_notional_per_order`, because a
ceiling any exported variable can raise is not a ceiling; `VIKE_SECRETS_PASSPHRASE` is refused
because nothing consumes it. A set, non-empty value is a hard startup failure naming the file, the
key and a paste-ready replacement line — an operator who set one believes a ceiling is armed, and a
build that quietly ignored it would trade uncapped.
**What else is in it.** Five venue bridges — **hyperliquid, aster, ibkr, alpaca and ctrader** —
taking the roster to fourteen. The headless daemon `vike-tradehub`, with an authenticated
observe/control wire and a thin `--observe` GUI client for it. The unified `vike-cli`: `backtest`,
`sweep`, `walkforward`, a human `trade` REPL, `config show` (every setting, its value, and where it
came from), and `mcp` — the agent surface. The **live reconciliation engine** behind
`VIKE_RECONCILE`, off by default. `vike-datahub`, the compute-to-data backtest server, and a Studio
that can drive it locally or remotely. An alerting engine inside the daemon, and an optional
confirm-gated Telegram control channel.
If you are upgrading an install made before this version, start the binary and read what it
prints: every leftover from the old layout is handled explicitly — silently, with a warning, with
a refusal naming the replacement, or by migrating itself — and the startup output names which.
## 0.1.0 — 2026-07-08 [#010--2026-07-08]
The first tagged release, at 389 commits. **One execution core behind a unified Strategy/Broker
seam**, so a strategy written once runs in backtest and live. **Nine venue bridges** — binance,
bybit, okx, deribit, oanda, ig, fxcm, dukascopy and polymarket — each speaking one
`ExecutionClient` contract, with live market data on the tick and L2 lanes. The **DataFusion +
Parquet history store**, with date partitioning, background compaction and retention, WAL crash
recovery and bounded-memory streaming scans. The accounting upgrade: a buying-power gate, margin
call, `order_target`, and multi-symbol, cross-venue mounts. A chart with log and percent scales,
panes and persisted workspaces. Tracing-native structured logging across the workspace.
The **error-lane closure** the venue contract still rests on landed here: no order may silently
vanish, a submit timeout re-queries rather than assuming an outcome, and post-reconnect history
replay recovers terminals and fills that landed inside a gap — across binance spot and perp, bybit,
okx, deribit and polymarket.
And the machinery that makes every later entry on this page checkable: the tag-triggered release
pipeline, a `cargo-deny` supply-chain gate, SHA-pinned CI actions with least-privilege tokens, a
pinned toolchain, and a workspace-wide `unsafe_code = "forbid"` lint.
# REST API (/docs/data/reference)
The REST base URL is:
```
https://data.vike.io
```
That is what the live OpenAPI document's own `servers` array declares, so a generated client picks
it up without configuration.
**This page is not the endpoint list.** The full endpoint reference is
[API endpoints](/docs/endpoints/data) — **68 endpoints across 6 namespaces**, one page per endpoint,
grouped by namespace. ⚠ One of those 68 is **not** on the base URL above: the WebSocket
`wss://vike.io/v1/stream` answers on `vike.io` rather than `data.vike.io`, so following the base
URL cannot reach it. It still validates the same credential and spends the same hourly counter.
What stays here is the set of conventions that hold **across** the `/v1` namespaces, so the
namespace pages do not restate them: how you authenticate, how identity and time windows are
spelled, what a failure looks like, how a call is labelled, and why a payload is byte-stable.
`onchain` and `hyperliquid` share one request path, so no route in either can quietly diverge from
what follows — the same auth, the same rate limiting, the same error mapping and the same usage
metering apply to every one of them.
`/v1/polymarket/*` is the exception. It answers on the same host, behind the same credential and
the same hourly budget, and it carries the same edge behaviour — the same key gate, the same
`Cache-Control` header — but none of the conventions below are guaranteed to hold there, and its
parameter spellings differ (`from`/`to` rather than `start`/`end`, for one). Read
[Polymarket](/docs/endpoints/data/polymarket) for its own.
## Authenticate [#authenticate]
Two credentials are accepted, and either one alone is enough:
```sh
curl -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/v1/onchain/tokens/search?q=USDC&limit=3"
```
```sh
curl -H "Authorization: Bearer $VIKE_OAUTH_TOKEN" \
"https://data.vike.io/v1/onchain/tokens/search?q=USDC&limit=3"
```
* **API key** — `X-API-Key: vk_...`. Create and revoke keys at
[vike.io/account](https://vike.io/account?tab=api-keys). HTTP header names are
case-insensitive, so the `X-API-KEY` spelling in the OpenAPI spec is the same header.
* **OAuth 2.1 bearer** — `Authorization: Bearer vk_oat_...`, discovery at
`https://vike.io/.well-known/oauth-authorization-server`.
The bearer is tried first, and a request **falls through** to the API-key header when the bearer is
invalid — including when that rejection came from cache — so a request may carry either or both,
and both reach the same account. The spec models this as a *list* of single-key requirements, which
in OpenAPI means OR, not AND.
Every call is counted against one hourly budget shared by MCP and REST; archive downloads are
authenticated with the same credential but do not spend it — see [Rate limits](/docs/data/rate-limits). Nothing under `/v1/` is reachable without a credential, the spec
and the interactive docs included: there is no exemption for a schema surface.
⚠ That gate is **header-only**. Exactly two headers can carry a credential — `Authorization` and
`X-API-KEY` — and nothing else is consulted. No cookie, no session and no `?token=` query parameter
authenticates a request to this host, so being signed in to vike.io in the same browser does
nothing here.
Key lifecycle, the OAuth discovery document and the revocation window:
[Authentication](/docs/data/authentication).
## Conventions [#conventions]
### Identity [#identity]
⚠ **There is no API-wide identifier.** Each namespace addresses its subject in its own vocabulary,
and the spelling is not interchangeable: `hyperliquid` takes a `coin`, `onchain` takes an
`address`, and `polymarket` takes a `token_id` — the outcome token, never the `condition_id`.
A response echoes the identity it resolved, so a client can pin what it was given rather than
re-deriving it.
### Time [#time]
Windows are **half-open** — `start` inclusive, `end` exclusive — across `onchain` and
`hyperliquid` alike: the filter is `ts >= start` and `ts < end`, so consecutive windows tile
without double-counting a boundary row. Both bounds accept ISO-8601 UTC or epoch milliseconds.
Timestamps in responses are epoch milliseconds, UTC.
⚠ Polymarket does not follow this. Its window parameters are `from`/`to` and its upper bound is
**inclusive** — see [Polymarket](/docs/endpoints/data/polymarket).
### Errors [#errors]
Branch on the status code, not the body. The mapping is deliberate: **400** for input the query
engine rejected, **401** for a bad or missing credential, **429** for the hourly limit (with
`Retry-After`), **503** for an upstream failure. A store outage is never reported as a 200 with an
empty body — a silent empty list is indistinguishable from "no data", which is how bad backfills
get built on top of an outage.
Full envelope shapes, and the 422 that comes from parameter validation before the query runs at
all: [Errors](/docs/data/errors).
### Metering [#metering]
Every call is recorded under a per-surface label, so archive downloads, REST calls and MCP calls
meter separately instead of all landing under one name. Two things produce those labels, and they
do not share one vocabulary:
* **One is derived from the request path.** `/v1//` becomes `api__`,
`/archive/...` becomes `archive_download`, and `/mcp` becomes `mcp`. Only the first two path
segments after `/v1/` are read, and an over-long segment is rejected rather than labelled — so a
wallet address or a token id can never reach a usage row.
* **Some routes report a shorter name of their own** — `api_onchain_wallets`, `api_hl_positions`.
`/v1/hyperliquid/positions` is `api_hl_positions` under that vocabulary and
`api_hyperliquid_positions` under the path-derived one, so do not assume one name per endpoint
when reading usage rows back.
**A rate-limited request is logged too**, with zero credits — a client hammering past its ceiling
would otherwise appear, in usage analytics, to have simply stopped calling. What the budget is, and
which surface stopped spending it on 2026-09-02, are on [Rate limits](/docs/data/rate-limits).
### Determinism and caching [#determinism-and-caching]
Aggregated floats in a successful payload are snapped to a fixed precision on the way out — once,
after the query has run, rather than inside each individual query. The query engine reduces a
`sum()` in an unpinned parallel order, so two executions of one byte-identical query return values
differing in their last digits; snapping is what lets a client cache, diff or content-address a
response at all.
Every gated response carries `Cache-Control: private, no-store`. The CDN in front of
`data.vike.io` must not cache an authenticated response — that would bypass both the gate and the
metering.
## The OpenAPI spec [#the-openapi-spec]
| Surface | URL |
| ---------- | -------------------------------------- |
| Spec | `https://data.vike.io/v1/openapi.json` |
| Swagger UI | `https://data.vike.io/v1/docs` |
| ReDoc | `https://data.vike.io/v1/redoc` |
All three sit under `/v1/` and behind the same key gate as the operations they describe, so a
browser cannot open Swagger UI or ReDoc without injecting a header. Fetch the spec with a client
that can set one, and read it locally:
```sh
curl -H "X-API-Key: $VIKE_API_KEY" https://data.vike.io/v1/openapi.json -o openapi.json
```
It is generated from the `/v1` namespaces it describes, and `polymarket` is not one of them — nor
is the WebSocket, which appears in no spec at all. What it does describe, it describes thinly — it
declares **no response schemas at all**: `components.schemas` holds two entries and both are error
types, so every 200 in the document is an undeclared `{}`.
[Schema and spec](/docs/endpoints/data/schema) has the full accounting.
## Related surfaces [#related-surfaces]
* **[Hyperliquid OHLCV](/docs/endpoints/data/hyperliquid/ohlcv)** — price candles now have one home,
Hyperliquid's own, read from HL's fill stream and served on the base URL above. Polymarket's
[`/v1/polymarket/ohlcv`](/docs/endpoints/data/polymarket/ohlcv) is a different thing under the same
word: one outcome token, priced in probability rather than currency.
* **[Polymarket REST](/docs/data/reference/polymarket)** — the hand-written reference for that
namespace, superseded for completeness by [Polymarket](/docs/endpoints/data/polymarket).
* **[Bulk history](/docs/data/archive)** is not a REST concern. Research-scale Polymarket data is the
Parquet archive: downloads do not spend the hourly budget, and you should fetch whole files.
* **One MCP server, two hosts.** `https://vike.io/mcp` is the canonical URL — see
[Data tools](/docs/ai/data). `https://data.vike.io/mcp` reaches the same server with the same tools
and the same API key; OAuth clients such as Claude Desktop should use `vike.io/mcp`.
# Polymarket REST (/docs/data/reference/polymarket)
Polymarket has its own REST surface under `/v1/polymarket/`. It answers on the same host, behind
the same API key and the same hourly budget as everything else — but it does **not** share the
conventions of the other namespaces on the [REST overview](/docs/data/reference), and that is why it is
documented by hand here rather than generated from the spec.
The spec is generated from the namespaces on the [REST overview](/docs/data/reference), and this
namespace is not one of them — it publishes no schema of its own: no `/openapi.json`, no `/docs`.
So every endpoint on this page is absent from the spec. The endpoints are public product;
only the *machine description* is missing, and this page plus
[Polymarket endpoints](/docs/endpoints/data/polymarket) is what stands in for it.
## One spelling [#one-spelling]
`/v1/polymarket/markets` is the only form. The older flat paths — `/v1/markets`,
`/v1/market_stats`, `/v1/ohlcv`, `/v1/orderbook`, `/v1/events` and `/v1/settlements` — were
retired on **2026-09-03** and return `404`. Add `/polymarket` after `/v1` and nothing else
changes.
## Endpoints [#endpoints]
### `GET /v1/polymarket/markets` [#get-v1polymarketmarkets]
The market directory. Returns an array; no parameter is required.
```sh
curl -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/v1/polymarket/markets?limit=1"
```
```json
[
{
"active": 1,
"closed": 0,
"condition_id": "0x553a941e8a11cac22eb746b6bbea0530695ee3e1b0693d80433d7b943c91cab7",
"end_date": 1863561600000,
"neg_risk": 1,
"slug": "will-trump-be-impeached-before-his-term-ends",
"tick_size": 0.01,
"token_ids": [
"99020283867209289889119738709824148614345922887757122199903300146152690995270",
"93407877955114874013681720453737167910717495090774867631160491764908235156561"
]
}
]
```
`token_ids` holds the two outcome tokens of a binary market, and a **token id — not the
`condition_id` — is what every other endpoint here takes.** `tick_size` is the venue's minimum
price increment for that market; `neg_risk` marks membership of a negative-risk set (a group of
mutually exclusive outcomes priced together).
### `GET /v1/polymarket/market_stats` [#get-v1polymarketmarket_stats]
Summary statistics for one outcome token. **Requires `token_id`** — without it the service answers
`400 {"error":"market_stats requires token_id"}`.
### `GET /v1/polymarket/ohlcv` [#get-v1polymarketohlcv]
Candles for one outcome token. **Requires `token_id`.**
This is a different endpoint from
[`/v1/hyperliquid/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv), which serves Hyperliquid's price
candles from a different store. Same word, different data: this one is priced in probability
(0–1), not in currency.
### `GET /v1/polymarket/orderbook` [#get-v1polymarketorderbook]
An order-book **snapshot** for one outcome token. **Requires `token_id`.** For history rather than
a snapshot, the [archive](/docs/data/archive) carries every book event, and it is the cheaper surface —
see the metering warning below.
### `GET /v1/polymarket/events` [#get-v1polymarketevents]
Settlement and lifecycle events. **Requires `token_id` or `token_ids`** (the plural form accepts a
comma-separated list).
### `/v1/settlements` — retired [#v1settlements--retired]
Both the bare path and the id-carrying form were retired on 2026-09-03 along with the other flat
spellings, and now return `404`.
⚠ The settlement **capability** is real, and this section previously said it was not. Only the
bare path is dead — the id-carrying form answers, and returns a populated row:
use [`GET /v1/polymarket/settlements/{condition_id}`](/docs/endpoints/data/polymarket/settlements-condition-id),
documented there with a live response, its one parameter, and the trap that on-chain settlement can
disagree with the market row's own `active`/`closed` flags.
## Metering [#metering]
Every call here spends one unit of the same hourly budget as MCP and the other `/v1` namespaces —
see [Rate limits](/docs/data/rate-limits). Archive downloads do not spend it. If you want book *history*
rather than a snapshot, download the archive partitions whole and read them locally: paging the
same history through these endpoints spends the budget, and reading the files remotely with a
Parquet client issues a request per column chunk.
## Related [#related]
* [Polymarket endpoints](/docs/endpoints/data/polymarket) — every endpoint of this namespace,
one `###` heading each, with the parameters read off the service and the settlement row
above documented in full.
* [MCP Polymarket tools](/docs/ai/data/polymarket) — the same data, agent-shaped: markets, market
detail, wallets and smart money.
* [Archive](/docs/data/archive) — full L2 book events, the taker tape and derived top-of-book as bulk
Parquet.
# Endpoint Overview (/docs/endpoints/data)
The Vike Data API is **68 endpoints across 6 namespaces**. This page carries the rules that hold
across all of them — where they live, how you authenticate, how they are metered, how they page —
and then hands off. It lists no endpoints itself; each namespace page below does that, with one
`###` heading per endpoint.
## The base URL [#the-base-url]
```
https://data.vike.io
```
That is what the live OpenAPI document's own `servers` array declares, and it is the only host that
answers the `/v1` namespaces. The same `/v1` path under `https://vike.io` returns **404** —
measured, not inferred.
One of the 68 lives on **`vike.io`** and is served by a **different application**. Following the
base URL cannot reach it:
| Endpoint | Host |
| ---------------------- | --------------- |
| `WEBSOCKET /v1/stream` | `wss://vike.io` |
It is the sharpest edge on this reference: it is spelled `/v1/stream`, which looks like the `/v1`
base, but `data.vike.io`'s `/v1/` prefix is served by an application with no such route. It exists
only on `vike.io`.
It is not a separate product. It validates the same credential and spends the same hourly counter
as everything under `/v1`.
## Authentication [#authentication]
Every endpoint on this reference is credential-gated. There is no keyless tier and no query-string
form — the credential always travels in a header.
```sh title="cURL"
curl -H "X-API-Key: $VIKE_API_KEY" \
"https://data.vike.io/v1/onchain/tokens/search?q=USDC&limit=3"
```
Two credential types are accepted, and either alone is enough:
* **API key** — `X-API-KEY: vk_...`. Header names are case-insensitive, so `X-API-Key` and
`X-API-KEY` are the same header.
* **OAuth 2.1 bearer** — `Authorization: Bearer vk_oat_...`.
One implementation validates both credentials on every surface. It tries the bearer first and
**falls through** to the API-key header if the bearer is invalid, so a request may carry either or
both. Both reach the same account.
The exception to "credential-gated" is two archive paths served outside the gate —
`/archive/manifest.json` and `/archive/samples/`. Those are on [Archive](/docs/endpoints/data/archive).
Key creation, the OAuth discovery document, and the caching window that makes revocation take a few
seconds: [Authentication](/docs/data/authentication).
## Metering [#metering]
**One hourly counter per credential, shared by every metered surface.** MCP calls and REST calls
draw from the same budget — the usage labels separate your analytics, not your allowance. There is
one value, and it is stated once, on [Rate limits](/docs/data/rate-limits), rather than copied here
where it would rot.
Two properties worth carrying before you get there:
* **Archive downloads are authenticated and recorded but do not spend the budget** (since
2026-09-02). Every other endpoint on this reference costs one unit per HTTP request.
* **The counter is per key, not per account.** A second API key is a legitimate way to keep a bulk
job from starving an interactive client. OAuth works the other way round: every OAuth token you
own shares one pooled counter.
## Errors [#errors]
Branch on the **status code**, not the body. Every `/v1` route maps errors the same way, and that
mapping is fixed: caller input the API rejected is a **400**, an upstream failure is a **503** —
deliberately never a 200 with an empty body, because a silent empty list is indistinguishable from
"no data".
The full table of status codes, the JSON envelope, and the one surface where an auth failure arrives
as HTTP 200: [Errors](/docs/data/errors).
Parameter bounds are declared on the route itself — a `limit` ceiling and its siblings — and
nothing intercepts a validation failure, so an out-of-range or malformed parameter gets the
framework's default **422**, not the 400 an engine rejection produces. This is also why the
published spec's `components.schemas` holds `HTTPValidationError` and `ValidationError`: a
validation failure is the only response on the whole API whose body the spec actually describes.
## Pagination [#pagination]
⚠ **There is no single convention.** Three different ones are in use, and the difference is not
cosmetic — a client written against one will silently truncate against another. Verified endpoint
by endpoint:
| Surface | Pagination | Field to pass back |
| ----------------------------------------- | --------------------------------------------------------- | ------------------ |
| `/v1/hyperliquid/*` — 18 of its 35 routes | keyset cursor | **`nextCursor`** |
| `/v1/onchain/*` | **none** — `limit` only, one page | — |
| `/v1/polymarket/*` | `/events` only, with its own cursor format — see its page | — |
**Hyperliquid pages by keyset, not offset**, for two reasons: the tables behind it hold billions of
rows, so `OFFSET n` re-reads and discards every skipped row, and a concurrent insert shifts rows
across page boundaries. The cursor is a base64url-encoded JSON object — **opaque; pass it back
verbatim**. Do not parse it, and do not construct one.
**`onchain` does not paginate at all.** The namespace declares a cursor on no route. Its routes take
a `limit` with a per-route ceiling and return one page. If you need more than the ceiling, narrow
the window — there is no second page to ask for.
**`nextCursor` is the only cursor spelling on the base URL.** Polymarket's `/events` tape pages too, but with its
own field name and its own cursor format —
[its page](/docs/endpoints/data/polymarket) states the convention. The two are not interchangeable, and a
client that assumes one field name silently stops after the first page against the other.
**A cursor is bound to the query that produced it.** It carries that query's own mode and filters,
and handing one to a different endpoint — or reusing it after changing `start`/`end` — is refused
with a 400, not silently honoured. The message says which: `nextCursor belongs to a different
endpoint`, or — when `start`/`end` moved — `nextCursor belongs to a different query mode`, telling
you to restart pagination without a cursor. Change a filter, restart paging.
**A non-null cursor does not mean there is more data.** The cursor is emitted whenever the page came
back *full* — exactly `limit` rows — so a result set that divides evenly by your `limit` hands you a
final cursor whose next fetch returns nothing. Stop when the cursor is `null` **or** the page is
empty — not on the cursor alone.
## The namespaces [#the-namespaces]
| Namespace | Endpoints | What it covers |
| ------------------------------------------ | --------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [hyperliquid](/docs/endpoints/data/hyperliquid) | 35 | Per-wallet Hyperliquid perp analytics — wallets, builders, cohorts and segments, positions, liquidations, per-coin metrics, HL candles. Half of the whole reference. |
| [onchain](/docs/endpoints/data/onchain) | 15 | Wallets, tracing, PnL and balances; token search, screening, holders and transfers; labels. Carries the API's **only non-GET route**. |
| [polymarket](/docs/endpoints/data/polymarket) | 11 | Prediction-market directory, per-outcome probability candles, order books, event tape, settlements, archive discovery, and two trader routes (a wallet's profile, smart-money signals). |
| [archive](/docs/endpoints/data/archive) | 3 | The bulk Parquet archive — the manifest, the free daily samples, and the gated store. Two of the three need **no credential**. |
| [schema](/docs/endpoints/data/schema) | 3 | The live OpenAPI document, Swagger UI and ReDoc. All three under `/v1`; the bare paths 404 by design. |
| [stream](/docs/endpoints/data/stream) | 1 | A credential-gated live bar WebSocket. ⚠ **Different host** (`wss://vike.io`), and invisible to the spec — OpenAPI has no WebSocket operation to emit. |
Six namespaces, 68 endpoints. Two of the six are documentation buckets rather than URL prefixes:
there is no `/v1/schema/` and no `/v1/stream/` under the base URL. Those pages group surfaces that
exist; they do not name a path segment.
The counts are load-bearing, so every one of them was re-counted rather than carried over:
`hyperliquid` 35 and `onchain` 15 are the routes each `/v1` namespace declares; `archive` 3 is the
three archive paths; `schema` 3 is the OpenAPI document plus its two rendered views; `stream` 1 is
the WebSocket on `vike.io`. `polymarket` 11 is the routes it declares: nine over the recorded CLOB and two trader routes.
## What this reference can and cannot do [#what-this-reference-can-and-cannot-do]
`GET https://data.vike.io/v1/openapi.json` is real and current, but it will not carry a code
generator, for three reasons that compound:
* **It covers 61 of the 68 endpoints.** It is the framework's own document for the application that
serves the three `/v1` namespaces on this reference, and it sees nothing else here — not the
WebSocket on the other host, not the file-served archive.
* **It declares no response schemas.** No `/v1` route declares a response model, so every 200 in the
document is an undeclared `{}`. `components.schemas` holds exactly two entries, and both are error
types.
* **It is behind the key gate.** The credential check covers the whole `/v1/` prefix,
`/v1/openapi.json` included, so a prospective user cannot read the spec before holding a
credential.
The spec describes **parameters** well and **payloads** not at all. Where a namespace page states a
response field, it was read from a real response; where a page does not state one, we did not have
one to show you. That is why these pages print few example bodies.
So this reference is hand-written — one page per namespace, one `###` heading per endpoint. Each
heading carries the method and full path, what the endpoint returns and when you would call it, and
its parameters where the route signature determines them. Where a signature does not determine them,
the page says so rather than guessing.
# Live stream (WebSocket) (/docs/endpoints/data/stream)
One endpoint: a credential-gated WebSocket that pushes **closed** OHLCV bars for the
subscriptions you hold. Those bars are **Hyperliquid's**, read from HL's own fill stream, and
they are the only series this endpoint carries. It runs in the `vike.io` application, not the
one that serves every `/v1` namespace on `data.vike.io`.
The REST base URL is `https://data.vike.io`. This endpoint is **`wss://vike.io/v1/stream`**.
A client that follows the documented base and connects to `wss://data.vike.io/v1/stream`
cannot reach it: that host's `/v1/` prefix is served by an application with no such route.
The path segment is the same word; the host is not.
This is a known inconsistency, and it now costs you two hosts rather than one: the REST
endpoint you backfill a gap from — [`/v1/hyperliquid/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv),
the same series this stream polls — **is** on `data.vike.io`. Moving the stream onto
`data.vike.io` is worth doing and has not been done; until it is, point the socket at `vike.io`
and the backfill at `data.vike.io`.
This endpoint is invisible to the OpenAPI spec — OpenAPI has no WebSocket operation to emit —
and it cannot be confirmed by probing (see [Why you cannot probe it](#why-you-cannot-probe-it)).
Everything below describes how the running service behaves, including the refused intervals,
the four-field subscription key and the closed-window boundary, all of which are covered by
tests. No frame on this page was captured from the live endpoint, and where the behaviour is
not settled, this page says so rather than guessing.
## Endpoint [#endpoint]
### `wss://vike.io/v1/stream` [#wssvikeiov1stream]
```
WEBSOCKET wss://vike.io/v1/stream
```
Subscribe to one or more Hyperliquid series and receive a `bar` frame each time a candle
closes. Use it when you want to be told a bar has completed without polling
`GET https://data.vike.io/v1/hyperliquid/ohlcv` on a timer; use REST for history, for warm-up,
and for backfilling a gap.
The handshake takes one optional query parameter. Everything else is negotiated over the
socket after it opens.
API key, for clients that cannot set request headers. Ignored when an
`X-API-KEY`
header is present, or when an
`Authorization`
bearer validates. An
`Authorization`
header that does
**not**
validate — malformed, revoked, expired, or not a bearer at all — falls through, and the
`token`
is read and honoured. See
[Authenticating](#authenticating-the-handshake)
.
The edge allows the WebSocket upgrade and drops a connection that goes **300 seconds** without
data. The server's \~15-second heartbeat keeps traffic well inside that window.
## What it streams: Hyperliquid, from our own node [#what-it-streams-hyperliquid-from-our-own-node]
The feed is driven by the same engine behind
[`/v1/hyperliquid/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv), reading one-minute and one-hour
rollups built from Hyperliquid fills captured by a node Vike runs itself. That is the whole
source: one venue, our own node, and the same series the REST endpoint serves. The one-minute
rollup trails wall clock by **18 seconds**, measured 2026-09-03, which is the lag the
15-second poll cadence is sized for.
`BTCUSDT`, `ETHUSDT`, a `_PERP` suffix, anything shaped like an exchange pair: a `subscribe`
carrying one is **accepted** — nothing validates the symbol against HL's asset list — and then
streams nothing, forever, because Hyperliquid has no such asset. You cannot tell that apart
from a quiet market.
There is no second series behind this endpoint to fall back to. Subscribe with the HL asset —
a perp ticker (`BTC`), a spot token (`HYPE`) or a raw spot index (`@107`) — and resolve it over
[REST](/docs/endpoints/data/hyperliquid/ohlcv) first if you are not sure it trades.
### Which markets and DEXs are reachable [#which-markets-and-dexs-are-reachable]
An HL bar is not identified by symbol alone, so the subscription carries two more fields:
| Field | Values | Default |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| `market` | `perp` or `spot`. Lower-cased before use, so `"PERP"` is accepted. | `perp` |
| `dex` | The core venue `HL`, or a HIP-3 builder-deployed perp DEX. 1–16 alphanumeric characters, upper-cased before use, so `"hl"` is the default. | `HL` |
Symbols follow the REST endpoint: a perp ticker (`BTC`), a spot token (`HYPE`) or a raw spot
index (`@107`). Whatever you send is stripped and upper-cased before use.
Builder DEXs deploy permissionlessly. The live set is read from the data and cached for 10
minutes, falling back to a fixed nine-name list only when that read fails — and an earlier
three-hour sample of that fallback saw only five of the nine actually trading. Subscribe with
the dex you want and read the error message, which **names the known set**. Note also that spot
lives on the core venue only (HL is perp *and* spot; the rest are perp DEXs), so
`market: "spot"` with a builder `dex` is a well-formed subscription that will never produce a
bar.
### What changed for a client, and what did not [#what-changed-for-a-client-and-what-did-not]
The wire protocol is unchanged: `subscribe`/`unsubscribe`/`pong`, the `bar` frame shape, the
monotonic `seq`, the `closed` flag, the \~15-second `ping`, the bounded outbound queue and the
seq-gap-then-backfill recovery path are all exactly as they were. A client written against the
published spec still connects, still subscribes with `{symbol, interval}`, and still parses
every frame. Since **2026-09-03** the data behind those frames is Hyperliquid's, so a client
written before that date sees three differences:
* **Symbols are HL assets** (see the callout above). The venue is pinned by construction — it
is Hyperliquid — and `market`/`dex` select within it.
* **Six intervals are gone.** `3m`, `2h`, `6h`, `8h`, `12h` and `1w` are not in HL's set. A
subscription to one is now an error frame, not silence.
* **`funding` is still always `null`**, for a new reason: HL candle rows carry `volume_usd` and
`n_trades`, never a funding rate.
## Authenticating the handshake [#authenticating-the-handshake]
The handshake tries three credential sources **in this order** and stops at the first that
produces an identity:
| Order | Source | Form |
| ----- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 1 | `Authorization` header | `Bearer vk_oat_...` — an OAuth 2.1 access token. Requires the `vk_oat_` prefix; revoked and expired tokens are rejected. |
| 2 | `X-API-KEY` header | `vk_...` — matched against an active API key. |
| 3 | `token` query parameter | The same API key, in the URL: `wss://vike.io/v1/stream?token=vk_...` |
Both header forms reach the same account — the same rule as the REST surface, see
[Authentication](/docs/data/authentication).
The browser `WebSocket` constructor cannot set request headers, so `?token=` is the only
way a page can authenticate. It puts a live credential in a URL. Prefer a header from any
client that can send one, and treat a key used this way as more exposed than one that is
not.
### What a refusal looks like [#what-a-refusal-looks-like]
If no source yields an identity the connection is closed with code **4401**, and it is closed
**before** the handshake completes. Most clients surface that as an HTTP **403** on the upgrade
rather than as close code 4401.
The server answers 403 identically for a connection refused before the handshake completes and
for a WebSocket path that matches no route. A 403 therefore cannot distinguish *your key was
refused* from *your URL was wrong* — `/v1/stream`, `/v1/stream-bogus` and `/v1/nope` all
look the same from outside. Check the URL by hand before concluding the credential failed.
## Subscribing [#subscribing]
After the socket opens, send JSON **text** frames. Exactly three values of `action` are
accepted:
| Field | Required | Notes |
| ---------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | Yes | `subscribe`, `unsubscribe` or `pong`. Anything else is an error frame. |
| `symbol` | For subscribe/unsubscribe | HL perp ticker (`BTC`), spot token (`HYPE`) or raw index (`@107`). Stripped and upper-cased by the server before use. |
| `interval` | For subscribe/unsubscribe | One of the seven below. |
| `market` | No | `perp` or `spot`. Default `perp`. |
| `dex` | No | `HL` or a builder DEX. Default `HL`. |
| `channel` | No | Accepted and **never read** — nothing on the server inspects it, so `"channel": "bars"` is decoration. Bars are the only thing this endpoint streams. |
```json title="Subscribe frames"
{"action": "subscribe", "channel": "bars", "symbol": "BTC", "interval": "1m"}
{"action": "subscribe", "channel": "bars", "symbol": "HYPE", "interval": "5m", "market": "spot"}
{"action": "subscribe", "channel": "bars", "symbol": "BTC", "interval": "1m", "dex": "XYZ"}
{"action": "unsubscribe", "channel": "bars", "symbol": "BTC", "interval": "1m"}
{"action": "pong"}
```
Intervals are the HL engine's **time** intervals:
```
1m 5m 15m 30m 1h 4h 1d
```
The REST endpoint accepts `Nt` bars (`15t` = 15 trades per bar). This one does not, for two
independent reasons, either sufficient:
* A tick bar has no wall-clock bucket, so *which bars have closed* — the only question this
poller is built to answer — has no tick analogue.
* Tick bars are not stable across polls. Trades are counted off from the start of the
**requested window**, so the bar boundaries are anchored to that window. The poller advances
its window every cycle, which re-partitions the same trades into different bars: the same
`ts` would come back with different OHLC, and a monotonic `seq` over contradictory bars is
worse than no bars at all.
A tick stream needs trade arrival to drive it, not a wider validator. Ask for tick bars over
[REST](/docs/endpoints/data/hyperliquid/ohlcv).
### The subscription key [#the-subscription-key]
A subscription is **`(symbol, interval, market, dex)`** — all four fields, and everything the
server does with a subscription (registering it, attaching, detaching, tearing it down) keys on
all four. Consequences:
* `BTC/1m` perp and `BTC/1m` spot are two independent subscriptions with two independent feeds
and two independent `seq` counters. They never collide, and neither do two builder DEXs.
* **Unsubscribe with the same four fields you subscribed with.** The key is re-derived from the
`unsubscribe` message, so unsubscribing `{symbol, interval}` does not cancel a `spot`
subscription — the keys differ, and the call is a silent no-op.
* Subscribing to a key you already hold on that connection is a no-op; so is unsubscribing
from one you do not hold.
There is no `subscribed` acknowledgement frame. The server replies to a valid `subscribe`
with nothing at all, and the next thing you receive is either a heartbeat `ping` or the
first `bar` — which for a `1d` subscription is up to a day away. Do not wait for an ack;
treat the absence of an `error` frame as acceptance.
### The error frame [#the-error-frame]
Every client-side rejection uses the same shape and, today, the same `code`:
```json title="The error frame"
{"type": "error", "code": "bad_subscription", "message": "unknown action 'frobnicate'"}
```
`message` is the only field that varies, and it now diagnoses rather than gestures — each fault
has its own message, checked in this order:
| What you got wrong | `message` |
| ---------------------------------------- | --------------------------------------------------------------- |
| No `symbol`, or a blank one | `symbol is required` |
| An interval outside the seven | `unknown interval '12h'; allowed: 1m, 5m, 15m, 30m, 1h, 4h, 1d` |
| A `market` that is neither | `market must be one of perp, spot; got 'futures'` |
| A malformed `dex` | `dex must be 1-16 alphanumeric characters; got 'not a dex'` |
| A well-formed `dex` that is not deployed | `unknown dex 'ZZZ'; known: ...` — the live set, named |
Malformed JSON, a non-object payload and an unknown `action` are caught earlier still, before
the subscription is built, and arrive with the same `code`. The socket stays open in every
case; a rejected message is discarded and the connection continues.
The format checks are free. The **membership** check — is this dex actually deployed — needs a
database round trip, and it runs **only when `dex` is not `HL`**: the core venue is always
known and can never be the unknown one, so an ordinary subscription never pays for it. That
check is also deliberately permissive: if the lookup itself fails, the subscription proceeds.
It is there to catch typos, not to be a gate.
## Server frames [#server-frames]
Two frame types reach the client in normal operation — `bar` and `ping` — plus the `error`
frame above.
### `bar` [#bar]
The frame shape is fixed, and a test holds it that way:
| Field | Meaning |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Always `"bar"`. |
| `symbol` | The symbol as you subscribed it, stripped and upper-cased. Not the resolved HL asset — a spot subscription for `HYPE` reports `HYPE`, not `@107`. |
| `interval` | The subscribed interval. |
| `ts` | Candle **open** time, epoch **milliseconds**, UTC, left-aligned to the interval — the same convention as the REST candle. A bar covers `[ts, ts+interval)`. |
| `open`, `high`, `low`, `close` | The candle. |
| `volume` | **Base-asset, taker-side** volume. Hyperliquid reports two rows per trade, one per side; the taker side is the figure venues publish, and counting both legs doubles it (24h BTC measured at $3.34B unfiltered against a per-trade-exact $1.67B, 2026-08-18). |
| `funding` | Always `null`, see below. |
| `closed` | `true`. Always, today — see below. |
| `market` | `"spot"`. **Present only when the subscription is not the default `perp`.** |
| `dex` | The builder DEX name. **Present only when the subscription is not the default `HL`.** |
| `seq` | Monotonic counter, **per subscription key**, incremented once per emitted frame. See [Sequence numbers and gaps](#sequence-numbers-and-gaps). |
The shape — the numbers are illustrative, and `BTCUSDT` here is a placeholder the server never
inspects, not a symbol that would return bars from HL:
```json title="The bar frame"
{
"type": "bar", "symbol": "BTCUSDT", "interval": "1m",
"ts": 1738411260000, "open": 1.0, "high": 2.0, "low": 0.5,
"close": 1.5, "volume": 10.0, "funding": null, "closed": true, "seq": 5
}
```
A subscription is keyed by four fields, but a default (perp/HL) frame names two — so it stays
byte-identical to the published spec and an existing client is unaffected. The cost is real and
worth planning for: **two subscriptions on one connection differing only by `market` or `dex`
produce frames you cannot tell apart** unless the non-default one is present, and `seq` cannot
demultiplex them either — it is per-subscription, and both start at 1. If you hold BTC-perp and
BTC-spot on one socket, route on the presence of the `market` key; do not route on `symbol`
alone.
An HL candle carries `ts, open, high, low, close, volume, volume_usd, n_trades` and no funding
rate, so `funding` is present and null on every frame. The two extra HL columns are **not** in
the frame at all: `volume_usd` and `n_trades` exist on the REST response and are dropped here,
because adding fields would change a published shape. Fetch them over
[REST](/docs/endpoints/data/hyperliquid/ohlcv) if you need them.
`closed` is likewise fixed: every frame is built with `closed: true`, and the poll only ever
asks for a window ending at the last **completed** bucket. Both fields exist for a
live-forming-bar upgrade — a live trade feed driving the same wire protocol and emitting
`closed: false` frames — that is not shipped, so do not branch on either field expecting
variation today.
### `ping` [#ping]
```json title="The ping frame"
{"type": "ping"}
```
## Heartbeat [#heartbeat]
The server sends `{"type": "ping"}` every **15 seconds**. The client must answer with a `pong`
message, which resets the miss counter to zero.
This is an **application-level** heartbeat carried in a JSON text frame. A WebSocket
protocol-level pong frame — what most libraries send automatically in response to a
protocol ping — never reaches the application and does not reset the counter. You must
send the literal `pong` action as a text message.
The counter is incremented immediately before each ping is sent, and the connection is
closed with code **1011** once it exceeds **3** missed pongs. A client that never ponged is
therefore dropped on the fourth heartbeat tick, roughly **60 seconds** after the socket
opened.
Note also that the server reads text frames only. Binary messages are not part of this
protocol.
## Sequence numbers and gaps [#sequence-numbers-and-gaps]
`seq` is what tells you a frame went missing, and its scope is the part that surprises
people: **it belongs to the `(symbol, interval, market, dex)` feed, not to your connection.**
There is one shared feed per active key on the server, with one poller serving every
subscriber. Its counter starts at 0 when the feed is created — that is, when the *first*
client anywhere subscribes to that key — and the feed is torn down when its *last* subscriber
leaves.
The consequences worth planning for:
* If you subscribe to a key somebody else is already watching, your **first frame's `seq`
is not 1**. It is wherever that feed had got to.
* If you are the only subscriber and you unsubscribe and resubscribe, the feed is destroyed
and rebuilt, and `seq` **restarts at 0**. A `seq` you saw before is not comparable with a
`seq` you see after.
* `seq` does not survive a server restart, and it is per-process.
Use `seq` for what it is designed for — detecting a **gap within one continuous
subscription** — and not as a durable cursor.
### Backpressure [#backpressure]
Each connection has a bounded outbound queue of **1000** frames. When a slow consumer's queue
is full the server counts the drop and moves on: the feed never blocks on one slow client. You
observe the loss as a jump in `seq`, and the documented recovery is to backfill the missing
window over REST — `GET https://data.vike.io/v1/hyperliquid/ohlcv` with the same `symbol`,
`interval`, `market` and `dex`, over the `ts` range you missed
([Hyperliquid OHLCV](/docs/endpoints/data/hyperliquid/ohlcv)). That is the same series the feed
polls, so the backfilled bars are the same bars.
A full queue is not drained to make room: the server discards the frame it could not enqueue.
Either way you see a `seq` gap, but the bars you lose are
the **most recent** ones, and the queue you are draining is stale. If your client falls
1000 frames behind, backfill from the last `seq` you received rather than assuming what you
are still reading is current.
## What closes the connection [#what-closes-the-connection]
| Cause | Code | Notes |
| ------------------------------------------------- | ------- | --------------------------------------------------------------------------------------- |
| No valid credential | `4401` | Sent before the handshake completes, so it fails; clients usually see HTTP 403 instead. |
| Four heartbeat ticks with no `pong` | `1011` | \~60 s after the last `pong`. |
| The client disconnects | — | Handled quietly. |
| Any unhandled server-side error on the connection | default | The socket is closed. |
On every one of these, all of that connection's subscriptions are detached, which tears down
any feed left with no subscribers.
## Freshness, and what the bars actually are [#freshness-and-what-the-bars-actually-are]
Each feed polls the HL series once every **15 seconds** and broadcasts whatever closed bars are
new since its last poll. The one-minute rollup behind it trails wall clock by **18 seconds**,
measured 2026-09-03.
**These are closed bars only. There is no live forming candle**, and the 18 seconds is not
delivery latency at the bucket boundary — it is how long after the boundary the bar becomes
readable at all. Stack the poll cadence on top and a closed bar reaches you somewhere in the
region of 18 to 33 seconds after it closed, depending on where the boundary falls between two
polls. That range is arithmetic over two constants, not an end-to-end measurement; nobody has
timed a frame on the wire (see [Known gaps](#known-gaps-in-this-page)).
Two mechanics behind that, both visible from outside:
* **The poller floors its own window.** It asks only for buckets that have already closed,
explicitly ending the window at the open of the forming bucket — otherwise it would ship a
partial bar stamped `closed: true` that nothing would ever correct. So a subscription never
emits the bar that was forming when you subscribed until it closes, and never emits a partial
bar at all.
* **A fresh subscription is quiet until the next boundary.** A new feed starts just before the
forming bucket's open, so its closed window is empty and it queries nothing until a bucket
closes. On `1m` bars at 15-second polls that is up to three silent cycles; on `1d` it is up to
a day. Warm-up history comes from REST, not from the stream.
A subscribe is validated for shape, not for existence. Everything past that point degrades to
nothing-arrives:
* **A symbol HL does not trade** (an exchange pair, a typo) returns an empty candle list,
forever.
* **A spot symbol the venue does not list** makes the poll itself fail; the server retries next
cycle and the client is told nothing.
* **A well-formed but undeployed `dex`** is normally caught at subscribe time, but that check
is permissive when the database is unreachable, so it can reach the poller and land in the
same place.
No error frame is emitted from the feed — only from `subscribe`/`unsubscribe` handling. Emitting
one is a deliberate non-change, not an oversight. **Resolve a symbol over
[REST](/docs/endpoints/data/hyperliquid/ohlcv) before you subscribe to it**, or you cannot tell a
typo from a quiet market.
## Load, and what a connection costs [#load-and-what-a-connection-costs]
Fan-out is per subscription key, not per connection: 1,000 clients watching `BTC/1m` on the
core perp produce one engine query per 15-second cycle, delivered 1,000 ways. Cost scales with
the number of **distinct keys** subscribed across the whole process, not with the number of
connections.
A `market: "spot"` poll resolves the spot-token map on every call, to turn `HYPE` into `@107`.
That is one additional query per feed per 15-second cycle, for as long as the subscription is
held. It is pre-existing behaviour of the underlying query rather than something the stream
added, but the stream is what turns it into a standing cost. The dex list is not in that class
— it caches for 10 minutes.
On metering: today this endpoint runs no rate-limit check and writes no usage record —
authenticating the handshake is the entire gate, and there is no second gate at the edge. That
is current behaviour, not a published allowance, so do not design around a stream being free.
The hourly budget and its documented home are on [Rate limits](/docs/data/rate-limits).
## Why you cannot probe it [#why-you-cannot-probe-it]
This endpoint cannot be confirmed or refuted by curling it, which is a large part of why it
went undocumented for so long:
* The server answers **403** identically for an unmatched WebSocket path and for a connection
refused before the handshake completes. `/v1/stream` and `/v1/nope` are indistinguishable
from outside.
* The edge behaves differently by protocol. Over HTTP/2, `Connection: Upgrade` is rejected
outright — measured as a **400**. Over `--http1.1` the request reaches the application and
returns 403. A probe run over h2 will look like proof the endpoint does not exist.
The only positive confirmation is a completed connection: open the socket over HTTP/1.1 with a
valid credential, subscribe to a symbol you know trades on HL, and wait for a bar.
## Known gaps in this page [#known-gaps-in-this-page]
* **No observed traffic.** No bar on this page was captured from the live endpoint, and the
18-to-33-second delivery window above is arithmetic over the measured data lag and the poll
cadence, not a timing anyone has taken on the wire.
* **Multi-worker behaviour is not specified.** A feed is per-process, and single-process
operation is what is assumed today. What `seq` would do across several processes is not
settled, so this page does not claim it.
# Benchmark (/docs/trader/analytics/benchmark)
`crates/vike-analytics/src/benchmark.rs` answers one question: how did a strategy's equity curve
behave *relative to* another curve — an index, a buy-and-hold, a second strategy. Every function
takes two curves, `strat_eq` and `bench_eq`, and most take the annualization factor
(`periods_per_year`, the same one the [report](/docs/trader/analytics/report) uses) and a risk-free
rate `rf`.
## The one rule: the curves must be the same length [#the-one-rule-the-curves-must-be-the-same-length]
Every public function begins by asserting the two curves have equal length and panics with
`equity curves must be the same length (got a vs b)` when they do not. The doc on `beta` records
where that came from: the ported module raised a `ValueError` there, and the panic mirrors it. The
module header is careful about what "port of" means now — *provenance, not a standing
obligation*: the old implementation is retired, no exporter survives, and no test in this crate
compares these functions against it. The [parity gate](/docs/trader/concepts/parity-gate) page carries
that decision.
So align first. The module's internal per-step `returns` is *length-preserving*: it pads a
zero-denominator step with `0.0` rather than skipping it, and its doc says why — unlike
`metrics::returns`, benchmark comparisons pair two curves element-wise and must not shorten.
## The statistics [#the-statistics]
| function | definition (from its doc) | degenerate answer |
| ------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `beta(s, b)` | `cov(rs, rb) / var(rb)` | `0.0` when the benchmark has zero variance |
| `alpha(s, b, ppy, rf)` | annualised Jensen's alpha: `CAGR_s − [rf + β · (CAGR_b − rf)]` | — |
| `correlation(s, b)` | Pearson correlation of per-step returns | `0.0` when either series has zero variance |
| `r_squared(s, b)` | `correlation²` | — |
| `tracking_error(s, b, ppy)` | annualised standard deviation of the return differential `rs − rb` | `0.0` below 2 returns |
| `information_ratio(s, b, ppy)` | annualised `mean(rs − rb) / std(rs − rb)` | `0.0` when tracking error is zero |
| `up_capture(s, b)` | mean of `rs` on the bars where `rb > 0`, over the mean of `rb` on those bars | `0.0` when the benchmark has no positive-return bars |
| `down_capture(s, b)` | mean of `rs` on the bars where `rb < 0`, over the mean of `rb` on those bars | `0.0` when the benchmark has no negative-return bars |
| `treynor_ratio(s, b, ppy, rf)` | `(annualized_return − rf_annual) / β` | `0.0` below 2 aligned returns, non-positive growth, or `β = 0` |
`benchmark_stats(s, b, ppy, rf)` computes the first eight in one pass and returns them as a
`BenchmarkStats` struct with one field per statistic — `beta`, `alpha`, `correlation`,
`r_squared`, `tracking_error`, `information_ratio`, `up_capture`, `down_capture`. Treynor is a
separate call.
The variance under `beta`, `correlation` and the tracking-error family is the same
`metrics::mean_variance` the rest of the crate uses (sample variance, `ddof = 1`), and the
covariance folds through `vike_model::py_sum`, so the summation discipline on the
[metrics](/docs/trader/analytics/metrics) page applies here unchanged.
## Treynor, stated fully [#treynor-stated-fully]
`treynor_ratio`'s doc spells out its terms because they differ from the others'. The numerator's
`annualized_return` is the *geometric* annualization of the strategy's own per-step returns — the
product of `1 + r` over the aligned returns, raised to `periods_per_year / n` — and
`rf_annual = (1 + rf)^periods_per_year − 1`. A `β` of zero (a flat or degenerate benchmark) is the
sentinel case, returning `0.0` rather than dividing.
Read each doc's formula before passing a rate. `alpha` places `rf` directly beside `CAGR_s` and
`CAGR_b`, both annual figures. `treynor_ratio` compounds it — `(1 + rf)^periods_per_year − 1` —
so there it is a per-period rate. The two functions are documented independently and neither
converts for you; a rate that is right for one is wrong for the other by a factor of the
annualization.
## Portability [#portability]
The header names the three sites that reach a power — `cagr`, `r_squared`, `treynor_ratio` — and
states that each is `libm::pow`, never `f64::powf`, citing the decision record that made the
`libm` crate the source of every transcendental. It also draws the conclusion that follows for
this module in particular: because these numbers no longer track the platform library the retired
implementation computed with, even the *spirit* of a cross-implementation comparison is gone here,
not just the mechanism. The [analytics index](/docs/trader/analytics) carries what the portability
costs and the gate that holds it.
## What this is not [#what-this-is-not]
These are curve-versus-curve statistics over two *equity* series. The two-series indicators —
spread, correlation and the rest computed bar by bar over two *price* series — live behind their
own seam in the indicator crate; the [pairs](/docs/trader/indicators/pairs) page covers them.
# Excursions (/docs/trader/analytics/excursions)
A trade's *maximum adverse excursion* is how far price moved against it before it closed; its
*maximum favourable excursion* is how far price moved in its favour. `crates/vike-analytics/src/
excursions.rs`'s header defines the module as pure functions over the trade list and OHLC bars,
with MAE and MFE derived by scanning the bars between each trade's entry and exit timestamps.
## Direction is inferred, and the field that looks like a side test is not one [#direction-is-inferred-and-the-field-that-looks-like-a-side-test-is-not-one]
`trade_direction(trade)` returns `+1` for long and `−1` for short, and its doc calls itself *the
single source of truth* for that inference. The reason it has to exist: `Trade.size` is recorded
as an absolute value — side is lost at the engine level — so `size >= 0` is not a side test. The
rule is that a profit on a price rise is a long and a profit on a price fall is a short; the
degenerate case, `pnl == 0` or `exit == entry`, defaults to long.
## MAE and MFE [#mae-and-mfe]
`mae_mfe(trade, bars, direction)` returns `(mae, mfe)` as positive fractions of entry price over
the trade's bar window — every bar with `entry_ts <= ts <= exit_ts`, inclusive. `direction` is an
optional override; `None` means infer it as above. For a long, adverse is the lowest low below
entry and favourable the highest high above it; for a short the two are mirrored. A zero entry
price or an empty window returns `(0.0, 0.0)`.
That last case matters more than it looks, because it is how the two fields on the trade itself
behave. `vike_model::Trade` carries `mae` and `mfe` as fractions of entry price, and its field
doc says *portfolio mode only; 0.0 = untracked*. There are two ways those fields get a value:
* **During a portfolio backtest**, the engine's bar loop folds each bar's high and low into the
running extremes of every open position — this bar's high/low, no look-ahead — and the
simulator's fill path resets those extremes to the fill price when a position opens or flips,
then writes the excursions at the moment a reduce, close or flip closes a trade, using the
prior sign of the position for direction. The per-bar fold is in
`crates/vike-backtest/src/engine.rs`'s `run` loop; the reset and the write are in
`crates/vike-backtest/src/engine/sim_broker.rs`'s `fold_fill`; the
[sim broker](/docs/trader/backtesting/sim-broker) page carries the rest of it.
* **After the fact, from a bar series**, through this module. `crates/vike-report/src/
excursions.rs`'s `backfill_excursions` is the worked example: a bare fill stream carries no
intrabar path, so a trade reconstructed from the live journal leaves `mae`/`mfe` at `0.0` —
correct, not a shortcut. When a bar series for the traded symbol exists in a history store, it
loads the bars spanning `[entry_ts, exit_ts]` and hands them to `mae_mfe` — which its header
calls *the one excursion primitive; no math is reimplemented here*. It is best-effort: an empty
window or a load error leaves that trade at `0.0` rather than failing the tearsheet, because
the store is an enrichment, not a hard dependency. The
[tearsheet](/docs/trader/reference/binaries/tearsheet) binary opens the concrete store behind its
`hist` feature and passes it in as a trait object.
The simulator writes excursions using `is_long`, the opening side it recorded. `mae_mfe` with
`direction = None` infers the side from `pnl` and the price change. Because `Trade.pnl` is gross
price PnL, the two agree for any trade with a non-zero PnL; for the degenerate zero-PnL trade the
inference defaults to long. Pass `Some(direction)` when you hold the recorded side and want it honoured.
## The edge ratio [#the-edge-ratio]
`edge_ratio(trades, bars)` is mean MFE over mean MAE across the trades, computed through
`mae_mfe` for each — its doc frames it as *entry quality*: a value above 1 favours profitable
excursions. The sentinels follow the crate convention: `0.0` with no trades, `inf` when mean MAE
is zero and mean MFE is positive (a trade set that never went against you).
## Running trade metrics [#running-trade-metrics]
`expanding_trade_metrics(trades)` produces one `ExpandingMetric` row per trade, in order, each
carrying the state *after* that trade: `n`, `win_rate`, `profit_factor` and `avg_pnl`. It is the
series a "metrics as the run progressed" chart is drawn from. `profit_factor` is `inf` while there
are no losing trades and gross profit is positive — the same rule as the
[metrics](/docs/trader/analytics/metrics) catalog's whole-sample version, applied at every prefix.
## What to read alongside [#what-to-read-alongside]
The [result type](/docs/trader/analytics/result) page describes the `Trade` fields these functions
read; the metrics page carries the sentinel convention the ratios share.
# Analytics (/docs/trader/analytics)
Vike Trader's analytics live in one crate, `vike-analytics`, whose header states the design in a
sentence: *the pure performance-analytics cluster, extracted verbatim out of `vike-backtest`*.
Nothing in it runs a simulation, signs an order or opens a store. It takes an equity curve and a
trade list and returns numbers — and because the backtest runner and the live tearsheet both call
the same functions, a backtest number and a live number over the same fills are the same number.
## Why it is a crate of its own [#why-it-is-a-crate-of-its-own]
`crates/vike-analytics/src/lib.rs`'s header carries the measurement that justified the split.
`vike-report`, the reader that renders a tearsheet from the live journal, reimplements no metric:
every figure it prints is a call into this catalog. While the catalog lived inside `vike-backtest`,
that reader compiled **32,259 lines to reach about 3,900** — 89% of what it built it could not
call, including the simulator, the paper exchange and the whole feature-gated harness tree. The
seam was already there: these modules name `vike_model` and nothing else (the one `vike_exec`
mention in the cluster was a doc comment in `result.rs`). Splitting on it lets a consumer take the
numbers without the machine that produced them. The header is explicit that the extraction *moved
code, not behaviour*.
## The rule the crate holds [#the-rule-the-crate-holds]
**`vike-model` is the only Vike dependency** — no `vike-exec`, no `vike-core`, no `vike-data`.
The header calls that the property the split exists to create, and says a would-be dependency on
any of the three is a signal that the item belongs in `vike-backtest`, not here. The manifest says
the same thing structurally: `crates/vike-analytics/Cargo.toml` declares `layer = 20` under
`[package.metadata.vike]`, the *domain* tier ("one hop over the vocabulary: a single-purpose pure
core"), and the layer gate fails any normal dependency that does not sit strictly lower. The
[architecture](/docs/trader/concepts/architecture) page carries the tiers.
The non-Vike dependencies are few, and each carries its reason in the manifest: `serde` (the
report and the zero-trade report serialize), `chrono` (calendar bucketing), `indexmap` (insertion
order where it affects a floating-point sum), `rand` (seeded resampling), `libm` (the manifest's
row names what first brought it in — the `erfc` normal CDF and the inverse normal CDF in
`overfit.rs`; the rule that every transcendental in the crate now goes through it is the header's,
below).
## What is in it [#what-is-in-it]
| module | page | what it is |
| --------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `metrics` | [Metrics](/docs/trader/analytics/metrics) | the catalog — sharpe, sortino, drawdown, CAGR, VaR, ES, SQN and the rest; the one home for every performance number |
| `result` | [The result type](/docs/trader/analytics/result) | `BacktestResult`, the run's raw output |
| `report` | [The report](/docs/trader/analytics/report) | `BacktestReport`, the flat serializable summary composed from it by `metrics::` calls only |
| `periods` | [Periods](/docs/trader/analytics/periods) | calendar bucketing, the monthly matrix, the drawdown table |
| `benchmark` | [Benchmark](/docs/trader/analytics/benchmark) | benchmark-relative statistics over two aligned curves |
| `excursions` | [Excursions](/docs/trader/analytics/excursions) | MAE/MFE per trade, the edge ratio, running trade metrics |
| `montecarlo` | [Monte Carlo](/docs/trader/analytics/monte-carlo) | seeded resampling of trade P\&L |
| `overfit` | [Overfitting](/docs/trader/analytics/overfitting) | PBO and the deflated Sharpe |
| `stability` | [Stability](/docs/trader/analytics/stability) | rolling parameter-stability scoring |
| `validation` | [Validation](/docs/trader/analytics/validation) | walk-forward and purged k-fold split generators |
| `stats` | [Survival statistics](/docs/trader/analytics/survival-stats) | the autocorrelation-robust t-statistic |
| `signal`, `signal_backtest` | [Signals](/docs/trader/analytics/signals), [The signal backtest](/docs/trader/analytics/signal-backtest) | probability to position; the vectorized signal backtest |
| `sizing`, `zero_trade` | [Sizing, and zero-trade diagnosis](/docs/trader/analytics/sizing-and-zero-trade) | the `PositionSizer` registry (eight sizers); the zero-trade cause analyzer |
A last module, `binutil`, is the pure argv glue the analytics-family binaries share; the header
notes that `store_root`, the one function that reads the environment, deliberately stayed in
`vike-backtest`.
## Numbers that do not depend on the box [#numbers-that-do-not-depend-on-the-box]
The header's "Cross-platform determinism" section is the part of this crate most worth carrying
into your own reading of a report. **Every transcendental in the crate is the `libm` crate, never
`f64`'s inherent method** — `libm::pow`, `libm::log`, `libm::exp`, `libm::erfc`. IEEE 754 does not
require `pow`, `log` or `exp` to be correctly rounded, so `f64::powf` is whatever the platform's
maths library does: the MSVC CRT on the Windows desktop, glibc on the Linux servers. The two
disagree by up to one ulp, and the equity curve produced by `signal_backtest::equity_from_pnl` was
measurably among the casualties, so a backtest's headline number depended on which machine ran it.
`crates/vike-analytics/tests/libm_platform_probe.rs` is the measurement that established it, and
`converted_functions_are_platform_invariant` in that file is the gate that now holds it. The
decision record behind the rule is dated 2026-08-25 and accepted.
Two refinements the header states because each was once got wrong: `sqrt` stays `f64::sqrt`,
because IEEE 754 *does* require it correctly rounded; and `powi` is banned at every arity, because
on MSVC in a `dev` build it becomes the CRT's `pow()` — the cure is a plain multiplication.
The header states the trade rather than hiding it: the `libm` crate does not agree with glibc
either (the probe measures 0.07%–10% of samples differing per domain), so the conversion moved
values on Linux too — where CI and live trading run. The numbers also no longer track the platform
library the retired reference implementation computed with. That is a deliberate choice of
reproducibility over bit-parity with an oracle that no longer exists; the
[parity gate](/docs/trader/concepts/parity-gate) page carries what retiring that oracle did and did
not license.
The parity rules the crate inherited are unchanged: `f64` end to end, the same expression order,
no fused multiply-add or fast-math; `%` on timestamps is floor-mod (`rem_euclid`); `IndexMap`
wherever insertion order affects the order of a floating-point sum.
## Two conventions you will meet on every page [#two-conventions-you-will-meet-on-every-page]
**Degenerate inputs return a sentinel, not `NaN`.** A ratio with nothing to divide is `0.0`, and
a ratio whose denominator is zero while the numerator is positive is `f64::INFINITY` —
`profit_factor` with no losing trades is the canonical case. The [report](/docs/trader/analytics/report)
page shows how that sentinel reaches JSON.
**One percentile.** Everything that quotes a quantile — VaR, the tail ratio, the Monte Carlo
bands — goes through `metrics::percentile`, and `metrics::PERCENTILE_METHOD` (`"numpy_linear"`)
names that convention wherever an artifact has to say which percentile produced its numbers.
## Where the numbers surface [#where-the-numbers-surface]
The standalone `backtest` binary prints a `BacktestReport` and ranks sweeps on it; the
[read a report](/docs/trader/guides/read-a-report) guide walks one. The
[tearsheet](/docs/trader/reference/binaries/tearsheet) binary builds a `BacktestResult` from
reconstructed live trades and fills its `LiveTearsheet` from the same catalog. And `vike-backtest`
re-exports `metrics`, `overfit`, `periods`, `report`, `result`, `sizing`, `validation` and
`zero_trade` at its own root so every pre-split path still resolves — while `benchmark`,
`excursions`, `montecarlo` and `stability` are deliberately *not* re-exported, because, as
`crates/vike-backtest/src/lib.rs`'s header records, no path of either spelling ever resolved
through that crate.
# Metrics (/docs/trader/analytics/metrics)
`crates/vike-analytics/src/metrics.rs` is the metrics catalog. The crate header calls it *the ONE
home for every performance number*: `vike-report` (the live tearsheet reader) and `vike-backtest`
both call it, so a live and a backtest tearsheet cannot drift. The module header describes it as an
exact port of the retired Python app's `analysis/metrics.py` — pure functions over slices. Two
inputs cover the whole catalog: an equity curve, `&[f64]`, and a trade list, `&[Trade]`.
`vike_model::Trade` is *a completed round-trip*; its doc fixes that `pnl` is gross price PnL,
`fees` is the round-trip cost, and `is_long` is true when the opening side was a buy. Where a metric
takes `periods_per_year`, the caller supplies it: the [report](/docs/trader/analytics/report) module
holds the two constants and passes the value through, and `BacktestReport::from_result`'s doc has
the `backtest` binary pick it from the profile's data kind and interval, since the result itself
does not carry that information. `DAILY_PERIODS_PER_YEAR` is 252 for daily bars, and
`DEFAULT_PERIODS_PER_YEAR` — the fallback its doc gives for non-daily and tick runs, a tick stream
having no fixed period — is kept equal to it so the Sharpe scale is at least consistent.
## Four conventions [#four-conventions]
**Sentinels, never NaN.** A metric with nothing to compute returns `0.0` — an empty trade list, a
curve shorter than two points, zero dispersion, too few returns for the estimator. A quotient whose
denominator is zero while its numerator is positive returns `f64::INFINITY`: `profit_factor` with
no losing trades, `recovery_factor` with no drawdown, `omega` with gains and no losses, and
`calmar`, `mar_ratio` and `ulcer_performance_index` with positive growth and no drawdown. The
module's tests name this the house `0.0`-sentinel convention, in place of NaN for degenerate
inputs. The report's `ser_f64_null_when_nonfinite` pins how `inf` reaches a `--json` consumer: as
JSON `null`, an explicit "no meaningful ratio" rather than a number.
**Compensated sums.** `mean_variance` is the shared core of every mean/std pair in the crate —
its doc names `sharpe`, `risk_return_ratio`, `returns_volatility`, `returns_skewness`,
`returns_kurtosis`, `sqn` and `benchmark::variance`, and the
[signal backtest](/docs/trader/analytics/signal-backtest)'s `sharpe_from_bar_pnl` and
`neg_sharpe_mini_backtest` reach it too — folding both moments through `vike_model::py_sum`, which is
Neumaier-compensated to match CPython's builtin `sum()`. Every current caller passes `ddof = 1.0`
(sample variance); the guards differ per caller and stay at each call site. Its doc records the one
reason it is `pub`: `vike_backtest::impact`, the [market-impact](/docs/trader/backtesting/market-impact)
model, folds its return series through the same function so the two cannot drift on summation
discipline.
**Transcendentals from the `libm` crate.** The header states the parity classes: metrics composed
of `+ − × ÷ abs compare` are bit-gated, while anything through `sqrt`/`pow`/`log` — `sharpe`,
`sortino`, `calmar`, `cagr`, `ulcer`, `mar`, `k_ratio` — is gated at ≤1e-12 relative, and a Python
`x ** 2` is mirrored as a `pow` call, never `x*x`. That call is `libm::pow`, and every `ln`/`exp` is
`libm::log`/`libm::exp`, never `f64::powf` or `ln`: `f64`'s transcendentals are the platform's libm,
which IEEE 754 does not require to be correctly rounded, so the same curve produced different
numbers on Windows and Linux. `crates/vike-analytics/tests/libm_platform_probe.rs` measured it —
before the conversion three of fourteen functions diverged across platforms over 600 curves each,
two of them here, `k_ratio` and `returns_skewness` — and its `converted_functions_are_platform_invariant`
holds the fix with hashes recorded by running on both boxes. `sqrt` stays `f64::sqrt` because the
standard *does* require it correctly rounded. The cost, stated in the crate header: the `libm`
crate does not agree with glibc either (0.07%–10% of samples per domain), so values moved on Linux
too — reproducibility chosen over oracle bit-parity, the rule the [parity gate](/docs/trader/concepts/parity-gate)
page carries.
**One percentile**, below.
## The curve-based catalog [#the-curve-based-catalog]
`returns` is the canonical primitive behind the ratios: per-bar simple returns `e[i]/e[i-1] − 1`,
*skipping* any step whose denominator is zero. Definitions are quoted from each function's doc
comment. A fraction of 0.01 is 1%; ratios are dimensionless; "ppy" is `periods_per_year`.
| function | inputs | definition | unit |
| ------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `total_return` | curve | return from the first to the last equity point | fraction |
| `returns` | curve | per-bar simple returns, zero-denominator steps skipped | fraction, one per bar |
| `max_drawdown` | curve | largest peak-to-trough drop as a positive fraction of the peak (0.2 = 20%) | fraction |
| `sharpe` | curve, ppy | annualized Sharpe of per-bar returns, risk-free 0; `0.0` if variance is 0 | annualized ratio |
| `sortino` | curve, ppy | annualized Sortino, target 0, risk-free 0 | annualized ratio |
| `calmar` | curve, ppy | annualized return (CAGR) divided by max drawdown | ratio |
| `mar_ratio` | curve, ppy | CAGR / max drawdown — its doc defines the same quotient as `calmar` | ratio |
| `omega` | curve, threshold | gains above the threshold / losses below it | ratio |
| `cagr` | curve, ppy | `(final/first)^(ppy/n) − 1`; `0.0` for flat, short, non-positive or overflowing input | annualized fraction |
| `ulcer_index` | curve | `sqrt(mean(drawdown_pct²))`, the drawdowns taken in percent | percentage points |
| `ulcer_performance_index` | curve, ppy | the Martin ratio, CAGR / Ulcer Index — penalized by drawdown depth *and* duration rather than max drawdown alone; `inf` with positive CAGR and zero Ulcer Index | ratio |
| `recovery_factor` | curve | total return / max drawdown; `inf` with no drawdown | ratio |
| `k_ratio` | curve | Kestner's K-ratio: slope / standard error of an OLS fit of log-equity against bar index | ratio |
| `risk_return_ratio` | curve | mean / std of returns (ddof 1), *not* annualized — Sharpe without the `sqrt(ppy)` factor | ratio |
| `returns_volatility` | curve, ppy | annualized standard deviation of per-bar returns (ddof 1) | annualized fraction |
| `returns_skewness` | curve | bias-corrected sample skewness (adjusted Fisher–Pearson, matching `pandas.Series.skew`); `0.0` below 3 returns or zero dispersion | dimensionless |
| `returns_kurtosis` | curve | bias-corrected excess kurtosis (matching `pandas.Series.kurt`; 0 for a normal distribution); `0.0` below 4 returns or zero dispersion | dimensionless |
| `tail_ratio` | curve | `abs(p95 / p5)` of returns — above 1 means a heavier upside tail; `0.0` for a zero or non-finite 5th percentile | ratio |
| `value_at_risk` | curve, confidence | historical VaR: the `1 − confidence` empirical quantile of returns, more negative = greater risk | per-bar return |
| `expected_shortfall` | curve, confidence | historical CVaR: the mean of the returns at or below the VaR threshold; always ≤ the VaR | per-bar return |
| `exposure` | curve, position sizes | fraction of bars with a non-zero position; `0.0` if the slices are empty or differ in length | fraction |
## The trade-based catalog [#the-trade-based-catalog]
PnL units are whatever `Trade::pnl` is denominated in.
| function | definition | unit |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------- |
| `win_rate` | fraction of trades with positive PnL | fraction |
| `profit_factor` | gross profit / gross loss; `inf` with gross profit and no losing trades | ratio |
| `expected_payoff` | average gross PnL per trade; `0.0` with no trades | PnL units |
| `payoff_ratio` | average win / abs(average loss); `0.0` with no wins or no losses | ratio |
| `sqn` | Van Tharp's System Quality Number, `sqrt(n) · mean(pnl) / std(pnl)` (ddof 1); `0.0` below 2 trades or zero dispersion | ratio |
| `long_ratio` | fraction of trades whose opening side was long; `0.0` for an empty list | fraction |
| `net_profit`, `gross_profit`, `gross_loss`, `total_fees` | the sum of every `pnl`; of the positive ones; of the negative ones, sign flipped to a positive loss; of every `fees` | PnL units |
| `avg_win`, `avg_loss` | the mean of the positive, or of the negative, `pnl`s; `0.0` when that side is empty | PnL units |
| `largest_win`, `largest_loss` | the largest positive and the most negative `pnl`; `0.0` when that side is empty | PnL units |
| `consecutive_wins`, `consecutive_losses` | the longest run of winning, or of losing, trades | count |
The last four rows carry no doc comment in the source; their definitions are read from the
function bodies: the sums and averages fold through `py_sum`, the two extremes are a plain
`max`/`min` fold over the winning or losing `pnl`s, and the run counts come from `max_run`, an
integer run counter with no summation in it. The run counts are the catalog's only `usize`
results; every other row returns an `f64`, except `returns`, which returns the `Vec` the
curve table lists.
`long_ratio`'s doc carries a provenance note for anyone comparing against old numbers:
`Trade::is_long` is populated by both the event-driven engine and the vectorized kernel, each
computing it from the position's prior sign before the closing fill overwrites it, so it is always
meaningful for trades this platform produced — which was not true of the Python app it was ported
from, whose legacy vectorized kernels defaulted the field to `false`. That is why old numbers read
`0.0`; nothing still compares the two.
## The one percentile [#the-one-percentile]
`percentile(sorted_vals, q)` is *the workspace's ONE percentile*: the `q`-th quantile by linear
interpolation between closest ranks, matching `numpy.percentile`'s default; `0.0` for an empty
slice, and `n == 1` needs no special case because the formula falls through to the single element
bit-identically. Its doc records why it went `pub`: a second implementation had already been
written in a research binary, computing a *nearest-rank* answer — always an observed sample, never
an interpolant — and printing it under the same `p10`…`p90` names an operator sizes positions from.
The two agree only where `(n − 1)·q` lands on an integer or the two neighbours are equal; on
`[0, 1, 2, 3]` at `q = 0.5` nearest-rank reads `2.0` against this function's `1.5`. The doc's
instruction: reach for this one; do not write a third.
The body does no comparison at all, so it cannot detect an unsorted slice — it will silently
interpolate between two arbitrary neighbours. Sort with `f64::total_cmp`, as `tail_ratio` and
`value_at_risk` do, never with `partial_cmp().unwrap_or(Equal)`: only the former is a total order,
so only the former actually leaves the slice sorted when a NaN is present.
`PERCENTILE_METHOD` (`"numpy_linear"`) is the *name* of that convention — what an artifact writes
down to say which percentile produced its numbers. It lives beside the body so the string and the
algorithm cannot come to disagree, and its doc asks that it change in the same edit that changes
`percentile` and in no other. A name rather than a version number, because the discriminating fact
is *which algorithm* and the tree holds more than two: the nearest-rank body above, a `len / 2`
median a research binary used to spell, and the latency gate's floor-rank percentile over integers,
deliberately a convention of its own. A third convention is a third name, visible in every artifact
carrying it; a version number would only have said that *something* changed.
## Where else the catalog runs [#where-else-the-catalog-runs]
The [report](/docs/trader/analytics/report) does no metric math of its own — every field is a
pass-through from the [result](/docs/trader/analytics/result) or a single `metrics::` call — and is
deliberately compact: a caller wanting the long-form catalog composes it for the shared fields and
adds its own from `metrics::`, `vike_report::LiveTearsheet` being the worked example.
[Monte Carlo](/docs/trader/analytics/monte-carlo) folds each resampled curve through `max_drawdown` and
its bands through `percentile`, its own copies of both having been deduplicated into this catalog;
the [survival statistics](/docs/trader/analytics/survival-stats) share `percentile`; the
[benchmark](/docs/trader/analytics/benchmark) statistics take their variance from `mean_variance` while
keeping a length-preserving returns series of their own, unlike `returns` here, because a
comparison pairs two curves.
# Monte Carlo (/docs/trader/analytics/monte-carlo)
One backtest gives one equity path — the order the trades happened to land in. `crates/
vike-analytics/src/montecarlo.rs` characterises how much of the result was that ordering: its
header describes the module as building many synthetic equity paths from the observed trade list
to characterise outcome dispersion. The [Monte Carlo and overfitting](/docs/trader/tutorials/monte-carlo-and-overfitting)
tutorial runs it end to end over a finished run.
## Two resampling methods [#two-resampling-methods]
`ResampleMethod` has two variants, each documented by what it preserves:
* **`Shuffle`** reorders the trade list, preserving the exact multiset of P\&Ls per path. Every
path therefore ends at the same terminal equity — start equity plus the sum of the original
P\&Ls — and what varies is the *path*: the sequence, and so the drawdown.
* **`Bootstrap`** samples with replacement, so a path may draw one trade several times and another
never. Terminal equity varies as well as the path.
## Running it [#running-it]
`mc_resample(trade_pnls, start_equity, n_sims, seed, method)` runs `n_sims` paths. Each path is
built by adding the resampled P\&Ls to `start_equity` cumulatively — the curve has one more point
than there are trades. The `McResample` it returns carries:
* `terminal` — the final equity of each path
* `max_drawdowns` — the max-drawdown fraction of each path, computed by `metrics::max_drawdown`
(the module's own copy was byte-identical and was removed in its favour)
* `curves_sample` — a representative sample of full curves, up to 20 paths spread evenly across
the simulations, for plotting
`confidence_bands(curves, qs)` then turns equal-length curves into per-step percentile bands: for
each requested quantile, a series of the value at that quantile across all paths at each step,
returned as `(quantile, values)` pairs in the order of `qs`. The percentile is
`metrics::percentile` — the workspace's one percentile, linear interpolation, named
`numpy_linear` on the [metrics](/docs/trader/analytics/metrics) page.
`risk_of_ruin(terminal_equities, ruin_level)` is the fraction of paths that end at or below the
level; `0.0` for an empty input.
## The summary [#the-summary]
`mc_summary(trade_pnls, start_equity, n_sims, seed, ruin_pct)` runs the shuffle method and
reduces the result to a `McSummary`:
| field | meaning |
| --------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `terminal_p5`, `terminal_p50`, `terminal_p95` | the 5th, 50th and 95th percentiles of terminal equity |
| `max_dd_p50`, `max_dd_p95` | the median and 95th-percentile max drawdown across paths |
| `prob_loss` | the fraction of paths ending at or below `start_equity` |
| `risk_of_ruin` | the fraction ending at or below `ruin_pct · start_equity` — ruin is defined by that product |
Because the summary uses the shuffle method, every path's terminal equity is the same and the
terminal percentiles collapse to it; the drawdown percentiles are the informative rows, and they
are exactly the question a shuffle asks — how bad could the sequence have been.
## The seed, and why this module is the worked example [#the-seed-and-why-this-module-is-the-worked-example]
The header states the reproducibility contract: seeded, so the same seed gives the same output
run to run within this implementation. The generator is `rand`'s `StdRng` seeded from the `u64`
you pass, and the manifest notes the dependency exists for *seeded resampling — statistical, not
bit, parity*.
The workspace's determinism gate, in `crates/vike-ops/tests/clock_pin.rs`, refuses any RNG
dependency inside the crates that must replay deterministically — and its doc names
`vike_analytics::montecarlo` as *the worked example* of the acceptable shape: a seeded generator
whose seed is a caller-supplied parameter is an input, not hidden state, and the code lives
outside the replay-critical scope. What the gate refuses is the dependency arriving inside the
fold at all, because the difference between `StdRng::seed_from_u64(seed)` and a thread-local
generator is one line in a diff nobody is looking at. The [replay](/docs/trader/concepts/journal/replay)
page is the other side of that rule.
## What is not pinned, and why [#what-is-not-pinned-and-why]
The header is explicit. Porting the retired implementation's exact random generator — its
Mersenne Twister plus its specific shuffle and range-draw routines — was ruled out as not worth it
for this one file, so no fixture was ever frozen and no test replays one. What survives is the
provenance of that decision: when the two implementations were run side by side, the same seed
produced *statistically equivalent, not bit-identical*, resampled paths. The tests here pin the
invariants instead — a shuffle path's terminal equals start plus the sum of P\&Ls exactly, the same
seed is deterministic, different seeds differ, a bootstrap path's steps are all drawn from the
original P\&Ls, and the summary's percentiles are ordered with its probabilities bounded in
`[0, 1]`.
## Read with [#read-with]
The [overfitting](/docs/trader/analytics/overfitting) page is the other half of asking whether a result
is real: resampling tells you how much the ordering mattered, the anti-overfitting statistics tell
you how much the *selection* did.
# Overfitting (/docs/trader/analytics/overfitting)
A Sharpe ratio is a number; whether it means anything is a different question. `crates/vike-analytics/src/overfit.rs` answers it three ways, each accounting for something the raw figure ignores: track-record length and the non-normality of returns (the probabilistic Sharpe), the number of configurations that were tried (the deflated Sharpe), and whether the configuration that won in-sample keeps winning out of it (the probability of backtest overfitting). A fourth function folds two of them — the deflated Sharpe and the probability of backtest overfitting — together with an optional walk-forward consistency, into a verdict. The module is a port of the statistics in *Advances in Financial ML*, and every entry point is pure over an equity curve or a trial matrix.
## One derivation feeds everything [#one-derivation-feeds-everything]
Every function on this page takes the same four inputs, and there is exactly one sanctioned way to produce them: `overfit::sharpe_moments`, which turns an equity curve into a `SharpeMoments` — `sr_per_obs`, `n_obs`, `skew`, `kurt`. The struct exists because two unit conventions each carry a footgun, and one of the two has already shipped as a real bug.
The textbook PSR is `Φ((sr − sr*)·√(n−1) / denom)` with no periods-per-year factor, and `denom = √(1 − skew·sr + (kurt−1)/4·sr²)` uses `sr` directly. Feed it an annualized Sharpe and annualization is counted twice — once through `√(n−1)`, again inside `denom` — which saturates PSR and DSR to about 1.0 and makes any significance test vacuous. `sharpe_moments` therefore computes `sr_per_obs` with `metrics::risk_return_ratio` (plain `mean/std`), the per-period twin of the `metrics::sharpe` you display. The two are different quantities for different consumers; only the first may reach this module. `sharpe_moments`'s own doc names the pin that caught the bug in production code.
The second convention is kurtosis. `metrics::returns_kurtosis` reports *excess* kurtosis (the pandas convention, normal = 0) while the PSR formula wants the non-excess form (normal = 3), so `sharpe_moments` rebases by `+3.0` exactly once, there. `n_obs` is deliberately left unclamped: a curve with fewer than two returns yields `n_obs < 2`, and `probabilistic_sharpe_ratio`'s own guard turns that into an honest `0.0` rather than a number derived from a degenerate curve.
## The three statistics [#the-three-statistics]
**`probabilistic_sharpe_ratio(sr, n, sr_star, skew, kurt)`** is the probability that the true Sharpe exceeds `sr_star`, given the observed per-period Sharpe over `n` observations. With `sr_star = 0` it is the single-run question — is this edge distinguishable from zero — and that is exactly what the Studio results pane shows for one run (`vike-studio/src/results.rs`'s `validation_rows`, which also prints the annualized Sharpe as a separate display row).
**`deflated_sharpe_ratio(observed_sr, trial_sharpes, n_obs, skew, kurt)`** is the PSR benchmarked not against zero but against `expected_max_sharpe(var_trials, n_trials)` — the maximum Sharpe you would expect from that many independent trials with that much spread between them, which is what a search produces from pure noise. `expected_max_sharpe` returns `0.0` for fewer than two trials or non-positive variance, and that detail matters below. `deflated_sharpe_with_effective_n` is the same test with the trial count replaced by `effective_n_trials(trial_return_series)`: `N / (1 + (N−1)·max(mean_corr, 0))`, clamped to `[1, N]`, computed from pairwise Pearson correlation of the trials' return series. Perfectly correlated trials collapse to about one effective trial; uncorrelated or anti-correlated ones stay near `N`.
**`pbo_cscv(matrix, n_splits)`** is the probability of backtest overfitting by combinatorially symmetric cross-validation. `matrix` is `T` observations by `N` trials of per-observation performance. The observations are cut into `n_splits` groups (which must be even — an odd count panics); for every way of choosing half the groups as in-sample, the in-sample best column is found (first-argmax, mirroring the oracle), its out-of-sample rank among all columns becomes a logit, and PBO is the share of logits at or below zero — the fraction of paths on which the in-sample winner ranked at or below the out-of-sample median. A non-finite value anywhere in the matrix, or an empty one, returns `NaN`: uncomputable and honest, never `0.0`, which would read as "no overfit".
## The verdict [#the-verdict]
`overfit_verdict(pbo, deflated_sr, wf_consistency)` scores points and returns a `Verdict` with an `OverfitLevel` and a list of reasons. PBO above 0.5 scores two points, above 0.2 one; a deflated Sharpe below 0.5 scores two, below 0.9 one; a walk-forward consistency below 0.5 — the fraction of windows profitable out-of-sample, which `vike_backtest::walkforward::WalkForwardReport::wf_consistency` supplies (see [walk-forward](/docs/trader/backtesting/walk-forward)) — scores one. Three or more points is `High`, one or two `Medium`, none `Low`, with "No major overfitting flags." as the sole reason. A `NaN` PBO adds a "not assessed" reason and no points, so an unassessed PBO is neither a pass nor a vote toward low risk.
In the Studio, `vike-studio-core/src/run.rs`'s `run_sweep_slice` computes a sweep's DSR with the effective trial count over every trial, and its PBO with `N_SPLITS = 16` — `C(16, 8) = 12,870` combinations, off the UI thread — only when there are at least two trials and sixteen observations, leaving the PBO `NaN` otherwise; the sweep's ranking sort is deliberately the *annualized* Sharpe, a display quantity that never reaches this module. `validation_verdict_rows` renders the verdict, with a `NaN` PBO shown as "—".
## The selection audit [#the-selection-audit]
`audit_selection` exists because the gates above are only as good as the trial count they are given, and the wrong count is easy to supply.
`effective_n_trials` derives its count from the trials it is fed, and `pbo_cscv`'s columns must be the full candidate set. Pick the best pair out of fifty, hand its curve alone to `deflated_sharpe_ratio`, and you read a reassuring number: a single trial has zero trial variance, so `expected_max_sharpe` benchmarks the winner against `0` rather than against the best-of-fifty a selection implies. `audit_selection` takes the full `trial_sharpes` and `trial_returns` plus the selected index precisely so the mistake is visible — a survivors-only call reports `n_candidates == 1` and appends "Only ONE candidate was audited" to the verdict. The module's own test demonstrates it: twenty candidates, one with a small drift, and the survivor-only audit reports a materially higher deflated Sharpe than the full audit.
The returned `SelectionAudit` carries `n_candidates`, `effective_trials`, `selected_sharpe`, `deflated_sr`, `pbo`, the `verdict`, and `selected_trades`. The trade count rides alongside Sharpe on purpose: a mean-reversion family dies by starvation — too few divergences — before it dies by unprofitability, and a Sharpe-ranked sweep cannot see that. Inconsistent inputs (an empty set, a length mismatch, an out-of-range index) return `None`: an unauditable selection is not a passing one.
It matters most for pair selection, a maximum over `N(N−1)/2` correlated candidate spreads — a search larger than any parameter sweep, and one that happens before it. The doc cites published work measuring the probability that an in-sample cointegrated pair is still cointegrated out-of-sample at about 4.9% against a 5.0% unconditional base rate: indistinguishable from a 5%-level test's false-positive rate. Pair search is exactly where a spurious winner enters.
## Related [#related]
[Survival statistics](/docs/trader/analytics/survival-stats) answers the same selection question by bootstrap, for signal panels rather than equity curves; [stability](/docs/trader/analytics/stability) scores the parameter neighbourhood of a sweep's optimum; [validation](/docs/trader/analytics/validation) supplies the splitters and the `combinations` helper `pbo_cscv` iterates.
# Periods (/docs/trader/analytics/periods)
`crates/vike-analytics/src/periods.rs` turns an equity curve plus its timestamps into the three
calendar views a tearsheet shows: returns per period, a year × month heatmap, and a table of the
worst drawdown episodes. Its header lists exactly those three entry points — `periodic_returns`,
`monthly_return_matrix` and `drawdown_table` — and everything on this page is one of them or the
label function underneath.
All three take the curve and a parallel `timestamps` slice in epoch milliseconds, UTC. On a
[result](/docs/trader/analytics/result) that is `equity_curve` and `equity_ts`, and `equity_ts` is empty
when a run did not track it — so calendar bucketing is only available for a run that carried its
timestamps. All three panic when the two slices differ in length; each one's `# Panics` section
states it.
## The period label [#the-period-label]
`period_key(ts_ms, period)` produces a comparable label for the calendar period containing a
timestamp. `period` is one of five strings and the doc gives the shape of each:
| `period` | label |
| ------------- | --------------------- |
| `"daily"` | `2024-03-15` |
| `"weekly"` | `2024-W11` — ISO week |
| `"monthly"` | `2024-03` |
| `"quarterly"` | `2024-Q1` |
| `"yearly"` | `2024` |
Anything else panics. The doc records that as inherited behaviour — the ported module raised a
`ValueError` — and adds that it is provenance only: nothing compares this function against the
old implementation any more.
The `"daily"` arm does not derive year-month-day locally. It delegates to
`vike_model::time::epoch_ms_to_utc_date`, the workspace's one day-law home, so this bucket key can
never drift from the [history store](/docs/trader/concepts/hist-store)'s `date=` partition math or the
end-of-day day-floor. The output is identical — the same `YYYY-MM-DD` off the same UTC calendar
day — and a test in the module proves the two ways of computing it agree across pre-1970
timestamps, the epoch boundary, a leap day and the millisecond either side of midnight.
## Periodic returns [#periodic-returns]
`periodic_returns(equity_curve, timestamps, period)` returns `(label, return_fraction)` pairs
grouped by calendar period, in chronological order. The doc defines the return as
`last_equity_in_period / first_equity_at_start_of_period − 1`, and the body makes "start of
period" precise: a bucket's entry equity is the last equity point *before* the bucket opened (the
previous period's closing value), except for the first bucket, which starts from the curve's first
point. A period whose entry equity is zero reports `0.0`. Insertion order is kept with an
`IndexMap`, mirroring the insertion-ordered dictionary of the port so the output order is the
order the periods were first seen.
## The monthly matrix [#the-monthly-matrix]
`monthly_return_matrix(equity_curve, timestamps)` builds the `MonthlyReturnMatrix` a heatmap
renders from: `years` (ascending), `matrix` (year → month → `Option`, present only for months
the curve touched) and `annual` (year → return). The annual figure is compounded from the months
present — the product of `1 + r` over them, minus one — not summed.
This is the structure the [tearsheet](/docs/trader/reference/binaries/tearsheet) binary's HTML output
renders as its monthly-returns table. `crates/vike-report/src/html.rs`'s header states the
arrangement: the table reuses `monthly_return_matrix` over the *same* equity curve and timestamps
the sheet was built from, nothing is re-derived, the table appears only when timestamps align one
to one with the curve, and a non-finite monthly cell renders as the empty marker rather than a
`+NaN%`.
## The drawdown table [#the-drawdown-table]
`drawdown_table(equity_curve, timestamps, top_n)` finds the top-N drawdown episodes sorted by
depth, worst first. Each `DrawdownEpisode` carries:
* `depth` — the fractional drawdown from peak, positive (0.20 = 20%)
* `peak_ts`, `trough_ts` — the timestamps of the peak and the trough
* `recovery_ts` — `None` if the curve never recovered
* `length` — bars from peak to trough
* `recovery` — bars from trough to recovery, `None` if not recovered
The scan is a single pass. A new peak (equity at or above the running peak) closes any open
episode with that bar as its recovery point; while below the peak the trough is tracked; an
episode still open when the curve ends is closed with no recovery and included. Episodes of zero
depth are dropped, so a monotonically rising curve yields an empty table, and the result is
truncated to `top_n` after sorting. The single worst episode's depth is the same number
`metrics::max_drawdown` reports on the [metrics](/docs/trader/analytics/metrics) page; this table adds
the where and the how long.
## Where it fits [#where-it-fits]
A `BacktestReport` does not carry any of these — it is deliberately compact, and the
[report](/docs/trader/analytics/report) page says so. Calendar views are composed by the caller that
has timestamps in hand: the HTML tearsheet today. A backtest that wants them calls these
functions over `equity_curve` and `equity_ts` directly.
# The report (/docs/trader/analytics/report)
`BacktestReport` is what the standalone `backtest` binary prints, what `--json` emits, and what a
sweep ranks on. `crates/vike-analytics/src/report.rs`'s header defines it by what it does *not*
do: the module *does no metric math of its own* — every number is a direct pass-through from the
[result](/docs/trader/analytics/result) or a single `metrics::` call, so a divergence here is a wiring
bug, not a math bug. The `matches_direct_metrics_calls` test holds that, asserting field-for-field
equality against calling `metrics::` directly on the same inputs.
## Why it lives outside the harness [#why-it-lives-outside-the-harness]
The header calls the module *feature-free by design*. The backtest harness sits behind
`vike-backtest`'s `hist-replay` feature, which pulls the optional data layer; the report needs
nothing but `serde` and the [metrics](/docs/trader/analytics/metrics), so it lives in the
`vike-model`-only crate instead, and the DataFusion-free leaf crates build the *same* summary
rather than re-assembling their own. `vike-report`'s `LiveTearsheet` composes this struct for the
fields the two share, so a live tearsheet and a backtest tearsheet cannot drift apart. The
harness's own `report` module re-exports everything and adds only the one piece that needs the
gated profile parser — `periods_per_year`, below.
## Deliberately compact [#deliberately-compact]
The struct doc is explicit that this is *not an everything-drawer*: it is the `backtest` binary's
human table and `--json` schema and the sweep's ranking source. A caller that wants the long-form
catalog — Sortino, Calmar, CAGR, SQN, VaR — composes this for the shared fields and adds its own
from `metrics::`; `LiveTearsheet` is named as the worked example.
| field | source |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| `name` | the profile's free-form `name`, if it had one |
| `final_equity`, `n_trades`, `per_symbol_pnl`, `funding_paid` | copied from the result |
| `total_return` | `metrics::total_return` — fractional, first to last equity point |
| `win_rate` | `metrics::win_rate` — fraction of trades with positive PnL |
| `sharpe` | `metrics::sharpe` — annualized, per-bar or per-tick returns |
| `max_drawdown` | `metrics::max_drawdown` — largest peak-to-trough drop as a positive fraction of the peak |
| `profit_factor` | `metrics::profit_factor` — gross profit / gross loss |
| `zero_trade` | `ZeroTradeReport::analyze` — `Some` only for a run that closed no trades *and* whose equity never moved |
## The human table [#the-human-table]
`Display` prints a fixed row set. The field docs are careful about which rows exist, because the
table's shape predates two of the fields and stays byte-identical for a run that does not use
them. The rows below are the module's own test fixture — a 1000 → 1010 → 990 → 1005 curve with
one winning and one losing trade — with the Sharpe row left as its label, since that value depends
on the annualization factor passed in:
```text
name: demo
final_equity: 1005.00
total_return: 0.5000%
n_trades: 2
win_rate: 50.0000%
sharpe:
max_drawdown: 1.9802%
per_symbol_pnl:
BTCUSDT: 5.00
```
* **`profit_factor` is not printed.** Its doc says the human table's row set predates the field;
it exists for ranking [objectives](/docs/trader/backtesting/objectives) and JSON consumers.
* **`funding_paid` prints only when nonzero**, so a spot backtest's table is unchanged; it is
always present in `--json`.
* **A zero-trade run prints its diagnosis instead of the table.** When `zero_trade` is `Some`,
`Display` writes `final_equity`, `n_trades: 0` and then the ranked causes from the
[zero-trade analyzer](/docs/trader/analytics/sizing-and-zero-trade) in place of the all-zero metric
rows. For any run with trades the field is `None`, skipped on serialization and absent from the
table.
## The JSON contract for the sentinel [#the-json-contract-for-the-sentinel]
`profit_factor` carries the house sentinel: `f64::INFINITY` when there are no losing trades but
some profit, `0.0` when there is neither. In `--json` it is serialized as `null` when non-finite,
so a consumer sees an explicit "no meaningful ratio" rather than a number.
`ser_f64_null_when_nonfinite`'s doc is precise about what it buys. `serde_json` maps a non-finite
float to `null` on its own (only float map *keys* are an error there), so the custom serializer is
not rescuing a failure — it pins the mapping as the documented contract, independent of the
serializer backend, so a format that hard-errors on non-finite floats could not break `--json` on
a degenerate run.
## Annualization: derived from the interval, not matched against a string [#annualization-derived-from-the-interval-not-matched-against-a-string]
`BacktestReport::from_result(name, result, periods_per_year)` takes the factor `metrics::sharpe`
needs, because the result itself does not carry it. Two constants live beside the struct:
`DAILY_PERIODS_PER_YEAR = 252.0`, the 252-trading-day equity convention for `1d` bars, and
`DEFAULT_PERIODS_PER_YEAR = 252.0`, the fallback for tick runs, kept equal so the Sharpe scale is
at least consistent.
The caller — the `backtest` binary — picks the factor through
`crates/vike-backtest/src/harness/report.rs`'s `periods_per_year(&BacktestProfile)`, the single
source of truth so the single-run binary and the [sweep](/docs/trader/backtesting/sweeps) rank the same
profile on the same Sharpe scale. Its doc records why it is *derived*: Sharpe annualizes by
`sqrt(periods_per_year)`, where the count must be the number of return observations a year
produces — one per bar. It used to return 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.9×` — silently, because the number still looked like a Sharpe — and making
`--rank-by sharpe` incoherent across a sweep whose points differ in interval.
The daily anchor is preserved exactly (`1d` still returns the daily constant, so no existing daily
report moves by a bit) and every other bar interval scales off it by how many of that interval fit
in a day: `252 · (86_400_000 / interval_ms)` — `1h` gives 6,048 and `1m` gives 362,880. Two limits
the doc states rather than hides: 252 is the equity convention while these markets trade 24/7, so
a defensible crypto anchor would be 365 — changing it would move every daily report and is a
separate decision, so 252 stays and intraday values inherit it. And a tick profile still gets the
default, because a tick stream has no fixed period. An interval that cannot parse falls back to
the default rather than fabricating a scale; the [run profile](/docs/trader/backtesting/run-profiles)
parser is what rejects a malformed interval.
## Reading one [#reading-one]
The [read a backtest report](/docs/trader/guides/read-a-report) guide walks a real table;
[runs on disk](/docs/trader/backtesting/runs-on-disk) covers what else a run leaves behind. The
[tearsheet](/docs/trader/reference/binaries/tearsheet) binary is the live equivalent — it renders the
journal, not a backtest, from the same catalog with a wider field set.
# The result type (/docs/trader/analytics/result)
Every backtest in Vike Trader ends in one type. `crates/vike-analytics/src/result.rs`'s header
calls `BacktestResult` *the one backtest result type*: it unifies the former `RunResult`,
`MultiSymbolResult`, `FastResult` and `PortfolioFastResult` into a flat superset where each
producer fills what it has and leaves the rest empty or zero — decision A of the
engine-unification spec. The [portfolio engine](/docs/trader/backtesting/portfolio-engine), the
[vector engine](/docs/trader/backtesting/vector-engine) and the live tearsheet's reconstruction all
produce this shape, and everything downstream — the [report](/docs/trader/analytics/report), the
[metrics](/docs/trader/analytics/metrics), the zero-trade diagnoser — reads it.
## The core fields [#the-core-fields]
* **`trades`** — the closed trades, as `vike_model::Trade` values. Empty when a kernel runs with
`build_trades = false`.
* **`equity_curve`** — equity after each bar. **`equity_ts`** carries the bar timestamps aligned
to it, and is empty when not tracked; the calendar analytics on the
[periods](/docs/trader/analytics/periods) page need it.
* **`final_equity`** and **`n_trades`** — copied straight into the report.
* **`per_symbol_pnl`** and **`per_symbol_curves`** — multi-symbol event runs only (PnL per
symbol, and the cumulative curve per symbol); single-symbol and vector runs leave them empty.
* **`intrabar_both_hit`** — event engines only: how many bars saw both the stop-loss and the
take-profit hit. Kernels leave it at 0. The adverse-first ordering that resolves such a bar is on the
[fill resolution](/docs/trader/backtesting/fill-resolution) page.
### What a `Trade` is [#what-a-trade-is]
`crates/vike-model/src/position.rs`'s `Trade` is a completed round-trip: `pnl` is *gross price
PnL* and `fees` is the round-trip cost, kept separate. `entry_ts` and `exit_ts` are the fill
timestamps of the opening and closing orders; `symbol` is the originating symbol (empty for the
single-symbol engine); `is_long` is true when the opening side was a buy. `mae` and `mfe` are the
maximum adverse and favourable excursions as fractions of entry price — *portfolio mode only; 0.0
means untracked*. The [excursions](/docs/trader/analytics/excursions) page covers how they are filled
and how they are recomputed from bars when they were not.
## The diagnostic counters, and why they ride on the result [#the-diagnostic-counters-and-why-they-ride-on-the-result]
Several fields exist for one reason the field docs state repeatedly: the `harness` and
`backtest`-bin entry points **drop the engine** once a run finishes, so anything a caller might
need in order to tell "zero trades because the strategy never traded" apart from "zero trades for
a mechanical reason" has to travel on the result. Each is a single copy taken *after* the fold
loop — never new counting on the hot path.
* **`stale_deferrals`** — how many times the opt-in stale-price wait discipline
(`vike_backtest::EngineParams::max_price_staleness_ms`, the
[staleness](/docs/trader/backtesting/staleness) page) *deferred* a market order instead of filling
it against stale data. Always 0 when that knob is `None` (the default) and for every
vector-kernel run. It separates "the tape never printed" from "the strategy never traded".
* **`session_deferrals`** — how many times the opt-in session gate
(`EngineParams::session_gate`, the [sessions](/docs/trader/concepts/instruments/sessions) law)
refused a fill because the venue was closed: resting orders deferred plus tagged-maker and
armed protective-stop passes skipped. Same defaults, same purpose: "few trades because the venue
was shut most of the tape" stays distinguishable.
* **`dropped`** — the engine's gate-drop channel (`vike_backtest::SimBroker::dropped`) mirrored
off the engine at end of run: `(symbol, reason, size, weight)` for every order a gate refused.
The reasons are the live `RiskGate`'s own strings (`"insufficient-margin"`, `"below-min-qty"`,
…), order-kind cash-gate drops, `"volume_cap"` and `"latency_reject"`. Empty for every
vector-kernel run and for any run where nothing was dropped. This is what the
[zero-trade analyzer](/docs/trader/analytics/sizing-and-zero-trade) reads to say "every order was
denied".
* **`warmup`** — the strategy's `Strategy::warmup()` requirement, in bars or ticks, captured
once at end of run; 0 for a strategy with no warm-up and for every vector run. It lets a
zero-trade run say "the strategy stayed in warm-up the whole window (lookback > bars)".
* **`funding_paid`** — the *net* perp funding cashflow over the run, received-positive and
paid-negative: the backtest twin of `vike_exec::Account.funding_paid`, broken out of
`final_equity` because a funding-carry strategy's P\&L is mostly funding and seeing it separately
matters. `0.0` when no funding was charged, for the tick path (the field doc notes
`run_ticks` does not yet accrue funding), and for a run whose funding netted to zero. It is
summed as Σ `MirrorFunding.amount`, the same quantity the R4 parity reconciles against the live
[account](/docs/trader/concepts/account).
## The one counter that is a measurement, not a channel [#the-one-counter-that-is-a-measurement-not-a-channel]
`below_min_reversals` counts fills this backtest executed that the *live* gate would have
denied — the field doc calls it a measurement of the one known live-vs-backtest divergence still
open, not a refusal channel. The condition is a below-minimum reversal: an order that opposes the
position, exceeds it (so it flips through flat), and falls below a venue floor. The simulator's
opening/closing split is direction-only, so it calls the whole thing closing and executes it; the
live `RiskGate` requires coverage and refuses.
Why it should read zero: a flip must exceed the position, so the condition needs
`|position| < flip < min_qty` — the position must already be below the floor, which an opening
order cannot produce. Against the real venue grids (the doc cites a `min_notional` of 5.0 for binance
and bybit and 1.0 for okx) that means reversing a position worth under about $5.
The counter exists so that reasoning is *falsifiable*: a non-zero reading on a real profile means
the argument is wrong. It does not need fixing — the obvious repair would change fill decisions
and, worse, would not converge the two engines. The source is `SimBroker::below_min_reversals`,
and the "KNOWN RESIDUAL DIVERGENCE" note lives with the live gate in `vike-exec`'s `risk.rs`; the
[risk and arming](/docs/trader/concepts/risk-and-arming) page carries that side.
## Consumers [#consumers]
`BacktestReport::from_result` composes the [report](/docs/trader/analytics/report) from a result and an
annualization factor. On the live side, the [tearsheet](/docs/trader/reference/binaries/tearsheet)
binary's `LiveTearsheet` builds a `BacktestResult` from reconstructed trades and an aligned equity
curve so that the same functions run over the same shape — the reason the type lives in an
engine-free crate.
# The signal backtest (/docs/trader/analytics/signal-backtest)
`crates/vike-analytics/src/signal_backtest.rs` is about a hundred lines of arithmetic. The event-driven simulator in [backtesting](/docs/trader/backtesting) — orders, a broker, real fee, slippage and adverse-fill semantics — produces the more honest number, and the header says it is the wrong tool for sweeping hundreds of signal variants: reaching a hundred lines should not cost 32,000 lines of simulator.
## The one invariant [#the-one-invariant]
`pnl[t] = position[t − 1] · log_ret[t]`. The position must be the one held *before* the return was known. The header states it as the line everything else rests on: every lookahead bug in a signal backtest is a violation of it.
## The kernels [#the-kernels]
**`apply_min_hold(positions, n_bars)`** locks a position in for `n_bars` after every entry or flip. When the position becomes non-zero at bar `i` — from flat, or by changing sign — bars `i ..= i + n_bars − 1` are forced to that value and any signal change inside the window is ignored, including a flip to the opposite sign; a minimum hold that any opposite signal could break would not be a minimum hold. The comparison against the previous bar reads the already-overwritten output, not the input — deliberate, matching the oracle: a bar forced by a lock is, for detecting the next entry, the position that was actually held.
**`bar_pnl(positions, log_ret, slippage_bps)`** applies the invariant and subtracts `slippage_bps` on every position change. The series starts flat, so entering at bar 0 is charged; so is an exit to flat. A NaN return is `0.0` PnL for that bar, and a change on that bar is still charged. A flip — long straight to short — costs exactly one slippage unit, not two; the doc says that is faithful to the oracle's 0/1 change indicator and is not a bug to "fix". Mismatched lengths truncate to the shorter.
**`equity_from_pnl(pnl)`** is `exp(cumsum(pnl))` from `1.0` at the first bar. A NaN contributes `0.0`, a declared divergence from the pandas oracle at that one bar — pandas emits a hole and resumes; this emits a finite, stale value — reachable only from a hand-built series, since `bar_pnl` never emits NaN. The exponentiation is `libm::exp`, and the crate header records why: the platform's own `exp` differs between the Windows desktop and the Linux boxes by up to one ulp, and this function — the equity curve — was measurably among the casualties, so a backtest's headline number depended on which box ran it.
**`trade_stats(positions, pnl)`** returns a `SignalTradeStats`: `n_trades` counts entries and flips (an exit to flat is not a new trade), `avg_holding_bars` is the mean run length of consecutive identical non-zero positions, and `win_rate` and `avg_pnl` are over non-flat bars only. Pass same-length slices: the first two walk the full position series while the last two walk a zip that truncates to the shorter, so a mismatch computes the two halves over different horizons with no error.
## Two numbers that look like ones you already have [#two-numbers-that-look-like-ones-you-already-have]
`sharpe_from_bar_pnl(pnl, periods_per_year)` is `mean / sd · √periods_per_year` over the PnL directly, with the *sample* standard deviation (`ddof = 1`), and it never exponentiates. `metrics::sharpe` takes an equity curve and derives simple per-bar returns from it. Both are correct in their own vocabulary and they disagree numerically, so the input is in the name — and a test exists solely so that substituting one for the other fails loudly instead of silently moving the headline. `ddof = 1` is load-bearing: it reproduces the oracle's `Series.std()` default, and the survival gate compares the result against a fixed threshold. Fewer than two non-NaN observations, or a zero or non-finite deviation, return `0.0` — zero fails every threshold. `max_drawdown_signed(equity)` is the same story: it returns the oracle's `dd.min()`, negative, and is what the [parity gate](/docs/trader/concepts/parity-gate) compares against 271 stored runs, while `metrics::max_drawdown` returns the positive magnitude the tearsheet renders. Do not unify them: the sign of the second is user-visible.
## The scorer a model search ranks by [#the-scorer-a-model-search-ranks-by]
`SharpeObjective` bundles `bar_returns`, `bands`, `min_hold_bars`, `slippage_bps` and `periods_per_year`; `neg_sharpe_mini_backtest(proba, &objective)` runs the frictioned pipeline verbatim — [`hysteresis`](/docs/trader/analytics/signals) → `apply_min_hold` → `bar_pnl` → `sharpe_from_bar_pnl` — and negates it, so a model search can argmin. It ranks a candidate [model](/docs/trader/research/models) by the Sharpe of a backtest of its own out-of-sample probabilities rather than by calibration against labels, in the frictioned shape so a churny candidate pays for its churn at selection time exactly as it would out of sample. Pass the same `periods_per_year` the reported backtest uses, or the two Sharpes cannot be compared.
A candidate is ranked by its Sharpe only when the mini-backtest produced evidence: it must trade (at least one non-flat bar after the min-hold), and its PnL must have a defined sample Sharpe (at least two bars, positive finite variance). Anything else scores `INFINITY`, the worst an argmin can receive. This cannot be left to `sharpe_from_bar_pnl`, which answers `0.0` for exactly these cases — right for threshold callers, and exactly wrong for an argmin, where `0.0` ranks a candidate that never opened a position above every negative-Sharpe candidate that traded. Not an edge case: with the default 0.75/0.25 entry bands over a hundred-odd-row slice, an all-flat candidate is common, and a search without this rule keeps answering "the model that never trades". A single-trade slice is not degenerate — one entry then holding gives `n − 1` market bars and a defined variance.
Two artifacts a caller accepts by ranking with it, identical for every candidate and so fair across them: the fold starts flat at the slice's first row, and the slice is scored as one series even where it crosses a seam such as an asset boundary. The caller's own per-span folding is the reported backtest; this is a ranking device. The [Rust study](/docs/trader/research/rust-studies) contract honours the rule from the other side: `vike-user-research`'s `StudyOutcome::metric` accepts a non-finite value precisely because this scorer deliberately scores a degenerate slice `INFINITY`, and rounding it to zero would turn a gap into an observation.
## Why the scorer lives here and not with the learner [#why-the-scorer-lives-here-and-not-with-the-learner]
`neg_sharpe_mini_backtest` ranks a model, so `vike-ml` looks like its home. It cannot go there, and the header says that is a merge gate rather than a judgement call: the body names only symbols from this crate and both crates declare `layer = 20`, while `crates/vike-ops/tests/layer_gate.rs` demands a strictly lower layer for every normal `vike-*` dependency (see [architecture](/docs/trader/concepts/architecture)). Which is the right answer anyway — the scorer is arithmetic over a position series and a return series, which is exactly what this module is. The learner side keeps only the choice of which score ranks its candidates.
The [survival statistics](/docs/trader/analytics/survival-stats) take this module's per-bar PnL as their input.
# Signals (/docs/trader/analytics/signals)
A model emits a probability per bar; a strategy holds a position. `crates/vike-analytics/src/signal.rs` is the conversion between them, and the reason it is a shared module rather than a helper inside one study is stated in its header: the offline research converts a concatenated out-of-sample probability series, while a live strategy converts one probability per bar. If those two ever disagree, a forward test silently stops testing what the research measured. So there is one `(state, p) -> state` function and two callers — the same argument that puts the [signal backtest](/docs/trader/analytics/signal-backtest) beside it.
## The bands [#the-bands]
`HysteresisBands` carries four thresholds, and its `Default` is the locked research constants: `entry_long = 0.75`, `entry_short = 0.25`, `exit_long = 0.55`, `exit_short = 0.34`. Entry and exit are deliberately different numbers. Entering long needs `p >= 0.75`, but staying long only needs `p >= 0.55`, so a position does not churn on noise around a single threshold; the gap between them is a dead zone in which the current state is simply held.
## The step [#the-step]
`hysteresis_step(state, p, b)` is one transition. `state` and the result are `-1` (short), `0` (flat) or `+1` (long):
* **From flat:** long if `p >= entry_long`, short if `p <= entry_short`, otherwise flat.
* **From long:** straight to short if `p <= entry_short` — the position does not transit through flat — flat if `p < exit_long`, otherwise still long. At `exit_long` itself the position is held.
* **From short:** straight to long if `p >= entry_long`, flat if `p > exit_short`, otherwise still short. At `exit_short` itself the position is held.
NaN means "no estimate this bar" — feature warm-up — and flattening on it would turn missing information into a trade. So a NaN returns `state` unchanged, from every state.
`state` is a bare `i8`, not an enum, so a caller can pass a value outside `{-1, 0, 1}`. Any such value falls into the short arm, mirroring the oracle's `else` branch, rather than being treated as an error or as flat; the contract is that callers only ever pass back a value the function itself returned.
`hysteresis(probs, b)` folds the step over a series, starting flat. State is carried across the whole slice and never reset, so a caller with several assets must call it once per asset or one asset's position leaks into the next. The module's test fixes the reading: `[0.80, 0.60, 0.60, 0.50, 0.20]` with the default bands yields `[1, 1, 1, 0, -1]` — enter at `0.80`, hold through `0.60` twice, exit at `0.50` (below `exit_long`), enter short at `0.20`.
## The other half of the handoff [#the-other-half-of-the-handoff]
This module is the last step of the study-to-strategy handoff, and its header is explicit that the first step has the same hazard. A probability only means what the research measured if the *features* behind it do — and a live strategy computes those per bar while the study computed them as a matrix. That seam is `crates/vike-indicators/src/feature.rs`'s `ColumnFeature`, one layer down, and `crates/vike-indicators/src/test_support.rs`'s `assert_stream_matches_batch` is its gate.
A wrong input produces a perfectly well-formed probability. The signal step cannot tell a probability computed from the features the model was trained on from one computed from a subtly different streaming version of them. The feature seam takes the same approach as this one — a single arithmetic, so that disagreement is not expressible: `feature.rs`'s header describes the batch form of a feature as the reference and the streaming form as the batch kernel re-run over a bounded retained tail, and names the one number left to get wrong, `ColumnFeature::reach`, how many trailing values the streaming form must retain. Declared too small, the streamed value is computed from a truncated history — silently, and past a threshold no short test reaches. That header records six indicators shipping with exactly that defect, green because the parity gate ran 400 bars while the trim first fired at 512. The [indicators](/docs/trader/indicators) catalog carries the streaming-equals-batch guarantee that now holds.
## Where it is used [#where-it-is-used]
The step is what a probability-driven strategy calls per bar after [inference](/docs/trader/research/inference); the fold is what a study calls over its out-of-sample series, and what the [signal backtest](/docs/trader/analytics/signal-backtest)'s model-selection scorer runs first, before the minimum hold and the per-bar PnL. Both callers reach the same function, which is the whole point.
# Sizing, and zero-trade diagnosis (/docs/trader/analytics/sizing-and-zero-trade)
Two modules of `vike-analytics` that serve the simulator rather than the tearsheet: `sizing.rs`, the sizer a backtest applies to every opening entry, and `zero_trade.rs`, the diagnosis a report prints when a run did nothing at all.
## Sizing [#sizing]
`crates/vike-analytics/src/sizing.rs` is the swappable sizer framework, an exact port of the original `core/sizing.py`. A sizer implements `PositionSizer` — one method, `size(&self, ctx: &SizeContext) -> f64`, returning the quantity (at or above zero) to open for one entry intent. The trait is `Send`-bounded so a `Box` can ride inside `EngineParams` onto a worker thread; every shipped sizer is plain data, so that costs nothing.
`SizeContext` is the live portfolio context handed in for one entry: `symbol`, `side` (`+1` buy, `-1` sell), `intent` (the raw size the strategy passed — used only by the pass-through sizer), `basis_price` (the current close), `equity`, `cash`, `multiplier`, `atr` (recent average true range, `0` when unavailable), `drawdown` (the account drawdown fraction, `0..1`), `risk_stop` (the protective stop for this entry, which the risk sizers need), and `open_risk` (the dollar risk already open across the book, for portfolio-heat caps).
The eight sizers:
| sizer | quantity |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PassThroughSizer` | `intent`, unchanged — the default |
| `FixedDollarSizer { amount }` | `amount / (basis_price · multiplier)`; `0` when the denominator is zero (a negative one is allowed, as in the port) |
| `FixedSharesSizer { shares }` | `shares` |
| `PctEquitySizer { pct }` | `pct · equity / (basis_price · multiplier)` |
| `PctVolatilitySizer { pct }` | `pct · equity / (atr · multiplier)` so the position's ATR-risk is `pct` of equity; the percent-equity formula when ATR is `0` |
| `MaxRiskPctSizer { pct }` | `pct · equity / risk_per_unit` with `risk_per_unit = abs(basis_price − stop) · multiplier`; `0` without a stop |
| `PortfolioHeatSizer { base, max_heat }` | wraps a base sizer and caps total open risk to `max_heat · equity`: the budget is `max_heat · equity − open_risk`, `0` if exhausted, and the base quantity is clipped to `budget / risk_per_unit`; passes through without a stop |
| `DrawdownThrottleSizer { base, sensitivity, floor }` | wraps a base sizer and scales by `max(floor, 1 − sensitivity · drawdown)` |
The two pure unit converters — `units_from_percent` and `units_from_value` — are not here: they moved to `vike_model::sizing` so the live `order_target_*` verbs share the one sizing law with the backtest engines, and call sites spell `vike_model::` directly.
**Where it runs.** `vike_backtest::EngineParams::sizer` is an `Option>`; when it is `None` the engine installs `PassThroughSizer`. `SimBroker::size_entry` runs the sizer on opening or increasing entries only and passes the size through when the submission is raw or reducing, building the context from the current price, the 14-bar ATR, `drawdown = max(0, 1 − equity/peak)` and the summed open risk. The [sim broker](/docs/trader/backtesting/sim-broker) page covers the fill side.
`crates/vike-backtest/src/ref_strategies.rs` lists the sizer first among the concepts that bind a strategy permanently to the simulator: it lives in `SimBroker::submit`'s `raw = false` arm, and the live path is always raw. Its `CapsSizersMask` reference strategy submits a size of `999.0` as a sentinel, relying on a `DrawdownThrottleSizer { base: PctEquitySizer }` to ignore `intent` entirely; submitted through the portable `Broker::submit_market`, which is `raw = true`, that sentinel would be taken literally. A strategy that leans on a sizer is a [simulator-only strategy](/docs/trader/strategies/simulator-only) by construction.
`crates/vike-analytics/tests/sizing_props.rs` holds the sizers to their contract with property tests over random but valid inputs — positive prices and multipliers, non-negative equity, fractional percentages — after finding that `size` had zero direct tests and only one golden run touched it. Every sizer must return a finite, non-negative quantity, and the two cap properties carry only a few-ULP round-trip epsilon, never a fudge factor. See [property tests](/docs/trader/developer/testing/property-tests).
## Zero-trade diagnosis [#zero-trade-diagnosis]
When a run closes zero trades and its equity curve never moves off the starting cash, the bare metrics table — total return 0%, Sharpe NaN, drawdown 0% — says nothing about *why*. `crates/vike-analytics/src/zero_trade.rs` is a pure, report-side composition over diagnostic counters the engine already accumulates on the [result](/docs/trader/analytics/result); it adds no counting to any fill lane.
**The trigger.** `ZeroTradeReport::analyze(&result)` returns `None` for any run that closed a trade or moved equity — "flat" is exact equality with the first value, which is correct because an idle run's equity is cash bit-for-bit every step, and an empty curve counts as flat. So a buy-and-hold run, with zero *closed* trades but a real marked position, is deliberately not flagged: its equity moved. The [report](/docs/trader/analytics/report) carries the result as `BacktestReport::zero_trade`, skipped on serialization when `None`, and its `Display` prints the diagnosis *instead of* the all-zero metrics table — a normal report is byte-identical to before the field existed.
**The inputs.** `ZeroTradeInputs` mirrors five things the run already recorded: `steps` (the equity-curve length — bars or ticks processed), `stale_deferrals` and `session_deferrals` (`BacktestResult`'s counters from the [stale-price wait](/docs/trader/backtesting/staleness) and the session gate), `denials` (the gate-drop channel `BacktestResult::dropped`, aggregated by reason in first-seen order by `aggregate_denials` — the live `RiskGate`'s own reason strings, cash-gate drops by order kind, `"volume_cap"`, `"latency_reject"`), and `warmup` (the strategy's warm-up requirement).
**The ranking.** `rank_causes` is deterministic, does no I/O, and always returns at least one cause, most probable first. Structural certainties lead with sentinel weights: `no-data` when `steps == 0`, else `warmup-shortfall` when `warmup >= steps` — the dispatch gate never opened, so `on_bar`/`on_tick` ran zero times; the default warm-up of `0` can never trip it. Then the "tried but was blocked" signals, each weighted by how many orders it blocked: `orders-denied`, with the reasons rendered largest-first; `stale-price`; `session-closed`. Ties keep that push order, because the sort is stable. When nothing fired, the single catch-all `no-orders` says so and carries the one hint the result cannot disambiguate: the strategy most likely never submitted, and in bar mode `snap_to_properties` re-tags bars as `SYMBOL.VENUE`, which a strategy routing on the bare symbol will miss.
`BacktestResult`'s doc states that `stale_deferrals`, `session_deferrals`, `dropped` and `warmup` are all zero or empty for every vector-kernel run. The [vector engine](/docs/trader/backtesting/vector-engine) therefore hands the analyzer nothing but a step count, and a flat vector run diagnoses as `no-data` when the slice was empty and as the catch-all otherwise.
Each `ZeroTradeCause` carries a stable kebab-case `code`, a one-line `headline` and an actionable `detail`; the `Display` form numbers them under "no trades executed - probable cause(s), most likely first:", and the JSON form serializes `causes` as an array.
# Stability (/docs/trader/analytics/stability)
An optimizer returns the best point. A parameter-stability graph shows what surrounds it: a broad plateau where the neighbouring configurations perform nearly as well, or a lonely spike where one configuration wins and its neighbours do not. The plateau is robust and the spike is fragile — a spike is the signature of a parameter fitted to the noise of one sample. `crates/vike-analytics/src/stability.rs` quantifies that reading into one number and one word.
## The score [#the-score]
`parameter_stability(scores, top_frac)` takes the trial scores of a sweep, in any order, and returns a value in `[0, 1]`. Only a plain score list is needed — no optimizer, no trial-generation machinery, nothing about which parameters produced which score. Any sweep that yields a scalar per point is an input; a [sweep](/docs/trader/backtesting/sweeps) ranked by an [objective](/docs/trader/backtesting/objectives) is the natural one.
The procedure, from the module header:
1. Sort the scores best-first.
2. Take the top `top_frac` fraction of trials as the neighbourhood of the optimum — at least one trial, however small the fraction.
3. Compute the plateau ratio: `mean(top scores) / best_score`.
4. Clamp to `[0, 1]`.
A ratio of `1.0` means every score in the neighbourhood equals the best — a flat plateau. A ratio near `0.0` means the neighbourhood averages far below its peak — an isolated spike. Zero or one trial returns `1.0`, since there is no evidence of spikiness to report. The module's own tests fix the reading: `[10, 10, 9.9, 9.8, 9.7, 1.0]` at `top_frac = 0.25` scores at least `0.9`, while `[10, 1, 1, 1, 1, 1, 1, 1]` at `top_frac = 0.5` takes the top four — `[10, 1, 1, 1]`, mean `3.25` — for a ratio of `0.325`.
Negative scores are handled by sign: the ratio is inverted as well as negated — `(−best) / (−mean)` — so that "least negative" reads as best, and a negative plateau such as `[−1, −1.1, −1.2, −1.3, −10, −20]` still scores at or above `0.8`. A best score of exactly zero returns `1.0` when the neighbourhood mean is also zero and `0.0` otherwise, and never divides.
`k = round(n · top_frac)` is computed with the module's `round_half_even` — round-half-to-even, the behaviour of Python's `round()` — not with `f64::round`, which rounds half away from zero. The doc is explicit about why: `k` must match exactly, or the number of trials entering the "top" neighbourhood silently diverges from the oracle the port was verified against. `2.5` rounds to `2`; `1.5` rounds to `2`; `0.75` rounds to `1`.
## The label [#the-label]
`stability_label(stability)` turns the score into a word: `"plateau"` at or above `0.8` (a broad, robust optimum), `"ridge"` at or above `0.5` (moderate sensitivity), and `"spike"` below that (a fragile, likely overfit optimum). The boundaries are inclusive at the top of each band — `0.8` is a plateau, `0.79` a ridge, `0.5` a ridge, `0.49` a spike.
## How to read it beside the other gates [#how-to-read-it-beside-the-other-gates]
Stability is a statement about the *shape of the sweep*, not about the significance of any one result. A plateau says the strategy's behaviour does not hinge on a precise parameter value; it does not say the plateau is above zero. That is the job of the [deflated Sharpe and the probability of backtest overfitting](/docs/trader/analytics/overfitting), which take the same sweep and ask whether its winner beats what a search of that size produces from noise. Read them together: stability says whether the result depends on hitting one parameter value, and the deflated Sharpe says whether the result is distinguishable from what the search would have found in noise.
# Survival statistics (/docs/trader/analytics/survival-stats)
The [metrics catalog](/docs/trader/analytics/metrics) answers "what was the Sharpe". `crates/vike-analytics/src/stats.rs` answers the question that catalog cannot: would this Sharpe survive its own autocorrelation, and the fact that it was selected as the best of many? Four pure functions over `&[f64]`, written for signal research — where hundreds of variants are swept and the survivors need to be more than lucky.
## The t-statistic [#the-t-statistic]
`newey_west_tstat(x, lag)` is the Bartlett-kernel Newey–West t-statistic for the mean of an autocorrelated series: `V = γ₀ + 2 Σ (1 − k/(L+1)) γₖ` over lags `1..L`, then `t = mean / √(V/n)`, with `L = min(lag, n − 1)`. A per-bar PnL series is autocorrelated by construction — a position held for many bars earns correlated returns — so the ordinary t-statistic overstates significance. This is the correction, and `lag` should be at least the holding horizon. NaNs are dropped, not propagated. A degenerate input (fewer than two observations, or a non-positive long-run variance) returns `0.0` rather than a NaN or an infinity: callers compare against a threshold, and zero fails every threshold, which is the safe direction. The test pins the arithmetic: `[1, 2, 3, 4]` at lag `1` gives exactly `4.0`.
`two_sided_p(tstat)` converts it: `2 · (1 − Φ(|t|))`, using the normal rather than Student's t because these series are thousands of bars long, where the two agree to well past any threshold anyone gates on. A NaN statistic maps to `1.0`; `1.96` lands on the familiar `0.05`.
## The false-discovery correction [#the-false-discovery-correction]
`bh_fdr(pvalues, q)` is the Benjamini–Hochberg step-up procedure. It controls the expected false-discovery rate — the share of rejections that are wrong — at `q`, and returns a rejection mask in the caller's order. Step-up is the part worth understanding: it finds the largest rank `k` whose sorted p-value is at or below `q·k/n`, then rejects every hypothesis at rank `k` and below. A middling p-value can therefore be rejected on the strength of the ones beneath it. The doc says so directly: that is the intended behaviour and not a bug to "fix". The test pins it — `[0.001, 0.08, 0.09]` at `q = 0.10` rejects all three, where rank two fails its own threshold and rank three passes.
## The block bootstrap [#the-block-bootstrap]
`block_bootstrap_sharpe(pnl, block_len, n_resamples, periods_per_year, seed)` returns the 5th and 95th percentiles of the annualized Sharpe over resamples built from fixed-length contiguous blocks — Kunsch's moving block bootstrap. Resampling individual bars would destroy the autocorrelation that makes a signal's Sharpe uncertain in the first place and would report a falsely tight interval; contiguous blocks of `block_len` bars preserve the local dependence structure. The test makes the point concrete: an alternating `±1bp` series resampled in blocks of two has every block summing to exactly zero, so every resample's Sharpe is `0.0`, which a per-bar bootstrap could not do 200 times. A series shorter than one block plus a bar returns `(0.0, 0.0)`: there is nothing to resample, and inventing an interval would be worse than declaring none.
Two honesty notes from the doc itself. The stationary bootstrap is a different procedure — geometric block lengths, wrapping at the series end — and is not what this function does; the name was corrected rather than reused. And the Sharpe inside the resample uses the *population* standard deviation, while [`sharpe_from_bar_pnl`](/docs/trader/analytics/signal-backtest) uses the sample form: not an inconsistency to fix, because one is a point compared against stored oracle numbers and the other is an interval that was never required to match anything.
## The reality check [#the-reality-check]
`hansens_spa(pnl_matrix, n_resamples, block_len, seed)` answers what a per-signal t-statistic cannot: given that the best of `k` candidates was *selected* as the best, is its performance still surprising? `pnl_matrix` is column-major, one inner vector per candidate, rows aligned by bar; shorter columns are truncated to the shortest. Each column is centred at its own mean, making the null "every candidate has zero expected PnL"; the maximum Sharpe across candidates is redrawn under that null by block bootstrap; and the p-value is the share of draws whose maximum matches or beats the observed one. An empty panel or a series shorter than one block returns `1.0` — reject nothing.
Candidates are resampled in lockstep. Independent per-column draws would destroy the cross-sectional dependence between candidates, make the maximum over columns stochastically larger, inflate the null distribution and cost the test power. The test pins it by duplicating a column: the p-value does not move.
What this computes is the recentred bootstrap max-statistic test — White's Reality Check on the maximum Sharpe — not the studentized variant, which standardizes each candidate by its own bootstrap variance before taking the maximum. The doc keeps the name because it is the module's public API, not because it promises the studentized form.
## Reproducibility [#reproducibility]
Seeded streams here reproduce run to run within this implementation, and are deliberately not required to match any other library's generator: reproducing an exact stream buys nothing a confidence interval consumes. Consumers use the interval, not its last digits. Cross-platform determinism is a separate matter and was a real defect: `crates/vike-analytics/tests/libm_platform_probe.rs` records that the variance folds in `block_bootstrap_sharpe` and `hansens_spa` were written as `powi(2)`, which one toolchain lowers to a platform `pow()` call in a `dev` build, so the same input hashed differently on two boxes. They are explicit multiplications now, and the two functions carry rows in that probe's `PLATFORM_INVARIANT` table.
## Related [#related]
The [deflated Sharpe](/docs/trader/analytics/overfitting) is the closed-form answer to the same selection question, for equity curves rather than signal panels. [Monte Carlo](/docs/trader/analytics/monte-carlo) resamples trade P\&L rather than bar blocks. The per-bar PnL these functions consume comes from the [signal backtest](/docs/trader/analytics/signal-backtest).
# Validation (/docs/trader/analytics/validation)
Ordinary cross-validation shuffles observations. On a time series that is a way of training on the future, so `crates/vike-analytics/src/validation.rs` provides the splitters that respect time: walk-forward windows, purged k-fold, and combinatorial purged paths, ported from chapter 7 of *Advances in Financial ML*. Every splitter returns index ranges over a series of length `n`; nothing here touches bars or returns. They are the machinery behind [walk-forward](/docs/trader/backtesting/walk-forward) and the [probability of backtest overfitting](/docs/trader/analytics/overfitting).
## The embargo, and which side of the block it sits on [#the-embargo-and-which-side-of-the-block-it-sits-on]
The header states the one modelling decision that shapes every purged splitter, and states only that: the embargo removes a window *after* each test block from training, because the observations are point-in-time, so there is no label overlap reaching backwards and no pre-block purge. Why the window sits on that side at all is not something the module says; the usual reason for an embargo is that a serially correlated series would otherwise leak the test period's information into a "future" training row, and that is the reading this page takes.
## Walk-forward by count [#walk-forward-by-count]
`walk_forward_splits(n, n_splits, mode)` divides the series into `n_splits + 1` chunks of `n / (n_splits + 1)` and returns one `Split` per test chunk from the second chunk on; the last split's test window runs to `n`. A `Split` is four indices with half-open semantics — train is `[train_start, train_end)`, test is `[test_start, test_end)`, and `train_end == test_start`. `WalkMode` picks the training window: `Anchored` is the expanding window, with `train_start` always `0`; `Rolling` is a fixed-width window of exactly one chunk immediately preceding the test window.
The module's test pins the shape: `n = 100`, four splits, chunk `20`. The first split trains on `[0, 20)` and tests on `[20, 40)`; the last anchored split trains on `[0, 80)` and tests on `[80, 100)`, while the last rolling split trains on `[60, 80)`. This is the splitter `vike_backtest::walkforward::walk_forward_strategy` calls to run a strategy over each out-of-sample window and stitch the windows into one equity curve; its `WalkForwardReport::wf_consistency` — the fraction of windows profitable out-of-sample — is the walk-forward input to `overfit_verdict`.
## Walk-forward by duration [#walk-forward-by-duration]
`purged_walk_forward_hours(n_hours, train_hours, val_hours, step_hours, purge_hours, expanding)` is the sibling parameterised by duration rather than by split count, because a research protocol is specified that way — "train on 14 days, validate the next 24 hours, step a day" — and converting that to a count depends on how much history happens to exist. It returns the same half-open `Split`.
`purge_hours` is the gap between the end of training and the start of validation, and it exists because the labels are forward-looking: a row at time `t` carries the return realised at `t + H`, so the last `H` hours of any training window have labels that reach into the validation window. Without the gap the model is trained on the answer. Set `purge_hours` to at least the horizon. Because the boundaries are half-open, `test_start − train_end == purge_hours` exactly; a `purge_hours` of `0` leaves train and test contiguous with no skipped row, matching `walk_forward_splits`.
`expanding` anchors `train_start` at `0` and grows the window; otherwise the window slides at a fixed length. The doc's own reading: sliding is the more honest form for "the model adapts to the recent regime"; expanding gives later folds more history. Edge cases are stated rather than left to chance: a zero `n_hours`, `train_hours`, `val_hours` or `step_hours` yields an empty vector instead of a panic or an endless loop, and a trailing validation window that would run past `n_hours` is dropped rather than truncated. The pinned example is `(100, 24, 10, 10, 5, false)`: seven splits, the first `{0, 24, 29, 39}` and the last `{60, 84, 89, 99}`.
## Purged k-fold [#purged-k-fold]
`purged_kfold_indices(n, k, embargo)` cuts the series into `k` contiguous test folds and, for each, returns `(train, test)` index lists where train excludes the test fold and an embargo window of `embargo` observations after it. The test pins `n = 10`, `k = 2`, `embargo = 1`: fold 0 tests on `0..5`, and its training set is `[6, 7, 8, 9]` — index `5` is the embargo.
## Combinatorial purged paths [#combinatorial-purged-paths]
`combinatorial_purged_splits(n, n_groups, n_test_groups, embargo)` generates every way of choosing `n_test_groups` of `n_groups` contiguous groups as the test set — `C(n_groups, n_test_groups)` paths, six for `C(4, 2)` — with a per-test-group embargo applied after each chosen group. The enumeration comes from the module's `combinations(n, k)`, which yields all index combinations of `0..n` in lexicographic order (`k = 0` gives one empty combination, `k > n` none) and is the same helper `overfit::pbo_cscv` iterates for its in-sample/out-of-sample paths.
## Choosing among them [#choosing-among-them]
By count when a fixed number of out-of-sample windows is what you want reported; by duration when the protocol is written in hours and the series length should not change the fold geometry. Purged k-fold and the combinatorial paths take everything outside the fold and its embargo as training, so most of their folds train on rows both before and after the test window — the pinned `n = 10`, `k = 2` example trains fold 0 on `[6, 7, 8, 9]`, rows later than its test block. Only the last fold (and the one combinatorial path whose test groups are the final ones) tests on a window strictly later than all of its training data. They yield many train/test paths from one series; walk-forward yields one per window, each trained only on what came before it. `walkforward.rs`'s header calls the walk-forward form "the honest way to backtest" a strategy without comparing it to the other two, so that contrast is this page's reading, not the header's.
# The cost model (/docs/trader/backtesting/cost-model)
`crates/vike-fills/src/broker_sim.rs` is, in its own words, the canonical cost model: **one**
definition of fills, fees and funding, an exact port of the Python oracle's `core/broker_sim.py`.
Three functions, three conventions, and one floor. The [sim broker](/docs/trader/backtesting/sim-broker)
and the [vector engine](/docs/trader/backtesting/vector-engine) charge through all three; the
[paper exchange](/docs/trader/concepts/paper-exchange) charges through the adverse fill price and the
fee only, and has no funding path at all. One shared definition behind all three consumers is why
the scalars live in `vike-fills` — a crate below both the simulator and the paper client rather
than a copy in each.
The conventions come from the header: `side_sign` is `+1` for a buy and `−1` for a sell;
`multiplier` scales every notional term; slippage is **adverse** — buys fill up, sells fill down.
## The three scalars [#the-three-scalars]
**`adverse_fill_price(raw_price, side_sign, slippage)`** is multiplicative:
`raw × (1 + side_sign × slippage)`, with the factor floored at `MIN_ADVERSE_FACTOR` so it can
never carry the price through zero.
**`fee(size, price, rate, multiplier)`** is `rate` on the multiplier-scaled notional:
`size × price × rate × multiplier`.
**`funding_charge(pos_size, mark_price, funding_rate, multiplier)`** is the perp funding cash
flow for a held position, `pos_size × mark_price × funding_rate × multiplier` — longs pay
positive funding, shorts receive.
## Where the slippage number comes from [#where-the-slippage-number-comes-from]
The engine hands `adverse_fill_price` a fraction, and `SimBroker::slippage_for` is the site that
decides it. Its doc promises a byte-identical default: with no impact model configured it returns
the `slippage` field itself — the same `f64`, with no arithmetic performed on it — because adding
a zero is not the identity on `−0.0`, and a parity gate is not the place to find that out. With a
[market-impact model](/docs/trader/backtesting/market-impact) configured, the size-dependent estimate
is **added** to the flat `slippage` for taker fills. Maker fills are exempt from the addend: an
order that rested and got hit was supplying liquidity, and charging it impact would fill a
resting limit through its own limit price. The flat `slippage` still applies to makers exactly as
it did.
Fees have their own precedence on `EngineParams`: `maker_fee` and `taker_fee` fall back to the
flat `fee_rate`, and an optional `fee_schedule` overrides that whole chain — with one shape,
`ProbabilityScaled`, routed to the exact per-fill `FeeSchedule::commission` because it has no
flat equivalent. A [run profile](/docs/trader/backtesting/run-profiles) refuses `[engine.fee]` beside
a non-zero `fee_rate`: two cost models would be configured and only one could win.
## The one deviation from the port [#the-one-deviation-from-the-port]
The header devotes most of its length to a single change, because it is the only place the Rust
arithmetic departs from the Python original. The original bounded neither operand of the
multiplication, and an adverse fraction at or past `1.0` on the losing side does not saturate —
it **inverts**: a sell of a positive quote comes back negative (raw `100`, `side_sign −1`,
slippage `1.5` → `−50`). `vike_model::round_to` preserves the sign, and the wrong-signed price
then flows into the notional floor, the fee, the cash flow, the position's average price and the
equity curve. Nothing downstream re-checks it.
`SimBroker::slippage_for` returns `slippage + impact_frac(..)`, and the impact addend is bounded
below — a cost is never negative — but never above. The header quotes the measurement: the
Almgren–Chriss model crosses `1.0` at roughly **18× the window's mean bar volume** once the
measured per-bar sigma reaches **20 %**, and a thin resampled series produces both halves of that
routinely.
The fix is a floor on the **factor**: `MIN_ADVERSE_FACTOR = 0.01`, the smallest factor the
function will ever multiply a quoted price by, so the adverse move saturates once it has consumed
99 % of the price. Its doc says which property is load-bearing — it is strictly positive, which
is the whole reason the fill cannot change sign — and that the magnitude is a policy choice: far
above any move that could still be economically meaningful, so it binds only on an estimate that
has already stopped meaning anything, and far enough from zero that the saturated fill is a
usable double rather than dust that would silently re-route through `SimBroker::apply_fill`'s
min-notional floor and turn a saturation into a dropped order.
The floor lives here and nowhere upstream because this is the only function that holds both
operands of the inversion, and every producer of a slippage number funnels through it — the flat
field, `slippage_for`'s impact sum, the engine's cash pre-check, the vector kernel and the paper
exchange. A floor inside an impact model could not work at all: a model never sees the flat
`slippage` it is added to, nor the side that decides which way the move points.
## What the floor does and does not do [#what-the-floor-does-and-does-not-do]
It **saturates rather than repairs**. A run that reaches the floor has a cost model whose output
stopped being a cost, and the fill it then books — 1 % of the quote — should look ruinous,
because it is. `SimBroker::slippage_saturations` counts the fills where the floor bound, so such
a run can say so; its doc notes that the counter is not yet mirrored onto `BacktestResult` the
way the deferral counters are, so the harness and the `backtest` bin cannot read it — a strategy
reads it off its context.
It floors the factor, **not the price**, so the fill keeps the sign of the quote it was priced
off: a genuinely negative quoted price — this workspace ships futures and EOD backfills, and
`vike_model::gross_notional` refuses to `.abs()` such prices away — passes through untouched.
It is byte-identical wherever it does not bind: the same two `f64` operations in the same order,
never contracted into a fused multiply-add, with the branch taken only where the frozen path
produced a zero or sign-flipped fill. And NaN is deliberately not caught — `factor <= 0.0` is
false for NaN, so a NaN slippage still yields a NaN price, because a NaN is a different defect
from an inversion and swallowing it would reintroduce the silent behaviour the floor exists to
remove. `adverse_move_saturates` exposes the predicate so a caller can count a saturation without
re-spelling the factor.
# Fill models (/docs/trader/backtesting/fill-models)
A fill model answers one question: given a resting order and the event that just arrived, at what
raw price does it fill, if at all. `crates/vike-fills/src/fill_model.rs` ships three tiers,
selected by the data available, and its header states the boundary every tier respects: each
returns a **raw** price, and the engine still applies `adverse_fill_price` slippage and routes the
fill through `compute_fill`, so parity is preserved. The models live in `vike-fills` rather than
in the backtester because the [paper exchange](/docs/trader/concepts/paper-exchange) is built from the
same primitives.
## The three tiers [#the-three-tiers]
**`BarFillModel`** — no ticks. It delegates to `order_fill_price`, the fill-trigger oracle in
`vike-model` that the live conditional-order emulator shares, and is the pre-tick behaviour.
**`TickFillModel`** — L1 quote bars. Market orders cross the real spread — buy at the ask, sell at
the bid — whenever the bar carries bid and ask; single-quote-tick bars (`high == low`) trigger
resting orders off the quote side; consolidated bars delegate to the bar oracle.
**`L2BookFillModel`** — L1 plus an L2 book. A taker is priced by **consuming the resting book**,
not by reading one tick price. The header calls it the R8 tier the sim-broker docs had named as
the follow-up since the queue model landed.
## Why the book tier exists [#why-the-book-tier-exists]
The header argues it as an engine defect, not one strategy's bug. Every tier above the L2 one
prices a taker at a price **nobody was offering**: `TickFillModel` fills a market buy at
`bar.ask` — the top of book, at any size — and on a trade-tick tape with no quotes it falls
through to the print, the price at which somebody *else* already took the liquidity. A taker can
obtain neither. The error is one-directional, always in the strategy's favour, and it scales with
size, because top-of-book is exactly the cheapest share on the ladder.
The measurement: on the Polymarket `btc-updown-5m` tape over 835 entries (`cheap_np_depth`), the
tape print averaged **0.2683** while the resting best ask at the same instant averaged **0.3063**,
and in 738 of 835 cases there was no resting ask at the printed price at all — roughly 4 cents
per share on a strategy whose whole edge bar is 5.5 cents. Any strategy that takes liquidity on
the tick path is mispriced the same way, which is why the fix lives in the engine.
## What the L2 tier changes, and what it deliberately does not [#what-the-l2-tier-changes-and-what-it-deliberately-does-not]
`L2BookFillModel`'s doc splits the question in two. **When** an order fills is unchanged:
`TickFillModel` decides it, including the stop and trailing trigger arithmetic and its `extreme`
bookkeeping. **What price** it fills at is re-derived by `book_taker_price`: the VWAP of
consuming the resting book for the order's own size, best level outward. A resting limit
additionally caps the walk at its own limit price, so it can only be filled by liquidity it
would genuinely have crossed.
Two properties follow. *No book, no change*: with no book this tier **is** `TickFillModel`, byte
for byte, so mixing a book-carrying symbol and a bookless one in a single run is well-defined
rather than silently lossy. And *insufficient depth does not fill*: when the displayed book
cannot cover the order's size — within its limit, if it has one — the order does not fill on
this event and stays resting, waiting for liquidity. That is the one behavioural difference a
strategy will notice, and the honest one; the alternative is to fill size the market never
showed. It also gives the tier the venue semantics it was built for: an order that is marketable
when the strategy decides and no longer marketable when the matching engine looks at it **rests
on the book** rather than filling or vanishing. Composed with a
[latency model](/docs/trader/backtesting/latency-models), the "decide at T, match at T + Δ" rule falls
out of machinery that already exists.
`book_taker_price` itself is hoisted to `vike_model::book_taker_price`. Its doc gives the
reason: the taker-price law is a property of an order book, not of a simulation, and the live
path needs the same rule — pre-trade sizing, a paper executor on a live feed, comparing realised
fills against the model. `vike-backtest` is a sibling of the live path, not below it, so the
shared home in `vike-model` is what lets both sides share one definition instead of drifting
apart silently.
## Selecting a tier [#selecting-a-tier]
`FillModelKind` in `crates/vike-backtest/src/engine/sim_broker.rs` is the selector: `Bar` (the
`EngineParams` default), `Tick`, and `L2Book`. The `L2Book` variant's doc states its requirement
and its guarantee: it needs a tape carrying `Tick::Book` events, degrades to `Tick` wherever no
book exists, and is reached only by asking for it, so selecting `Bar` or `Tick` is byte-identical
to the engine before the variant existed. The book it prices from is `SimBroker`'s per-symbol
replay book, which is empty except while `run_ticks` replays a tape carrying book events and is
folded under `L2Book`'s delta law — see [tick replay](/docs/trader/backtesting/tick-replay).
The walk **is** the slippage. `FillModelKind::L2Book`'s doc and `EngineCfg::fill_model`'s both
say to set `slippage = 0.0` beside it: stacking the flat `adverse_fill_price` haircut on top
would charge the same cost twice.
In a [run profile](/docs/trader/backtesting/run-profiles) the knob is `fill_model = "l2book"` in
`[engine]` — the one accepted value, case-insensitive, tick mode only. Absent means `Tick`,
byte-identical. `EngineCfg::fill_model_kind` treats any other spelling as a fail-fast validation
error at load, for a reason its doc names: a typo like `"l2_book"` must not silently select the
optimistic `Tick` model and undo the depth-cap it was meant to enable. Whether a resting limit
at a touched price has actually reached the front of the queue is a separate opt-in on the same
lane — [the queue lane](/docs/trader/backtesting/queue-lane).
# Fill resolution (/docs/trader/backtesting/fill-resolution)
OHLC cannot reveal the sequence inside a bar. When several resting orders trigger on the same
bar, the engine has to choose an order to apply them in, and the choice changes the result: a
stop and a take-profit that both lie inside the bar's range cannot both have filled against one
position. `crates/vike-fills/src/fill_resolution.rs` is the one shared answer — an exact port of
the Python oracle's `resolve_intrabar_fills` — and its header states the two rules.
## Adverse first [#adverse-first]
Apply **adverse** fills (stop and trailing) before **favourable** fills (limit). That is the
pessimistic ordering: when the data cannot say which side of the range the price reached first,
the engine assumes the side that hurts. `resolve_intrabar_fills` takes the `(order, fill_price)`
pairs that triggered this bar together with the position size **before** any of them, and
returns the pairs sorted adverse-first.
## The bracket cap [#the-bracket-cap]
When more than one order **reduces** the current position in the same bar — an SL + TP bracket —
the total reduction is capped to the position size, adverse-first, so the profit target cannot
also fill after the stop has already flattened the position. The returned sizes are the
bracket-capped ones.
The ambiguous bar is not silently resolved. The function returns `both_hit` — `1` when the cap
applied this bar — and the header says why: it is surfaced on the result for honesty.
`BacktestResult::intrabar_both_hit` in `crates/vike-analytics/src/result.rs` is the counter,
described there as event-engine only; the vectorized kernel leaves it at `0`. A run whose stops
and targets kept landing in the same bar is a run whose bar interval is too coarse for its
brackets, and the count is how you find that out from the [result](/docs/trader/analytics/result).
## Where it runs [#where-it-runs]
The step is part of every event-engine fill pass. `fill_pending_tick_queued`'s doc gives the
shape of the tick lanes as trigger → **resolve** → dispatch → tagged lane, the resolve being this
function; the [queue lane](/docs/trader/backtesting/queue-lane) inherits it unchanged from the frozen
path it mirrors. The [paper exchange](/docs/trader/concepts/paper-exchange) is built from the same
`vike-fills` primitives, which is the point of the function living there: one definition of what
happens on an ambiguous bar, on both sides of the backtest-equals-paper law.
`vike-fills` was extracted verbatim from the backtester — a pure relocation with no behaviour
change — and the crate header's parity rules apply: `f64` end to end, the same expression order
as the oracle, no fused multiply-add or fast-math. See [the parity
gate](/docs/trader/concepts/parity-gate) for why none of that is relaxed to make a test pass.
# The harness registry (/docs/trader/backtesting/harness-registry)
A profile's `[strategy] name` resolves through `crates/vike-backtest/src/harness/registry.rs`'s
`strategy_by_name`: a registry name plus its TOML `params` table becomes a
`Box>`. An unknown name is a validation error at load time — the harness
fails the profile fast rather than silently no-opping on a typo. Dispatch through the box is
deliberately dynamic: the harness runs one strategy per profile, so a single vtable indirection
per bar or tick is noise next to loading and replaying the data slice.
## Two halves of one registry [#two-halves-of-one-registry]
This file is the **simulator half**. The portable half moved down to
`crates/vike-strategy/src/registry.rs`, whose `strategy_by_name` is generic over the
broker so the same table serves the backtest simulator and the live daemon. The reason is a
dependency one: a return type naming `SimBroker` made "run the strategy I backtested" impossible
for the headless daemon without depending on `vike-backtest`, which would drag the whole
simulator — and, under the DataFusion feature, the Arrow tree — into the binary that signs real
orders.
What stays here is what genuinely cannot leave: five reference strategies implemented against
`SimBroker` by design, two portable strategies that reach this crate's own internal modules, and
the `rhai` arm, whose `vike-script` dependency sits at the same layer rank as `vike-strategy` and
so cannot be named from there. Everything else falls through to the portable registry,
monomorphised at `SimBroker`.
`STRATEGIES` is the union of both halves, in this order:
```
buy_hold rotation_top_k bracket_per_symbol gated_weights caps_sizers_mask
tick_pair_mse cheap_catch_updown_fair_value sport_copy_follower grid
dca_accumulate spread_maker gueant_maker trailing_scalper momentum
funding_carry funding_capture pairs_zscore
```
The order is wire-visible — the data service's strategy listing documents "in its declared
order" — so the split kept it byte-identical rather than grouping the halves. Every name on it
resolves with default params; `backtest --list` prints it, and the Studio consumes it as its
native-strategy list. The ten portable names each have a page of their own, from
[`buy_hold`](/docs/trader/strategies/templates/buy-hold) to
[`pairs_zscore`](/docs/trader/strategies/templates/pairs-zscore); the registry they come from is
[the portable registry](/docs/trader/strategies/the-registry).
## The seven simulator-only names [#the-seven-simulator-only-names]
`vike_strategy::registry`'s `SIMULATOR_ONLY` carries, as data any consumer can read, the names that
cannot leave `vike-backtest` and the reason for each — so a daemon refusing one can say why
rather than "unknown strategy". Two distinct reasons, and the distinction matters:
| name | why it stays |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `rotation_top_k` | simulator-bound: reads the `SimBroker` `symbols`/`schedule` fields directly |
| `bracket_per_symbol` | simulator-bound: measures the attached protective `stop` the `SimBroker` fill loop resolves |
| `gated_weights` | simulator-bound: drives the `SimBroker` cash-gate `weight` submit argument |
| `caps_sizers_mask` | simulator-bound: drives the position sizer via `SimBroker`'s `raw=false` submit path (a `999.0` sentinel size) |
| `tick_pair_mse` | simulator-bound: measures `SimBroker::position_of` against the engine's latency shadow |
| `cheap_catch_updown_fair_value` | lives in the simulator crate: reaches its internal `cheap_np_ask`/`fair_value` modules — portable in principle, not yet moved |
| `sport_copy_follower` | lives in the simulator crate: reads a signal series the replay harness synthesizes — portable in principle, not yet moved |
The first five name simulator machinery that no registry work makes live; the last two are
genuinely portable `impl` strategies that merely live here, and moving them is future
work rather than a law. The backtest side asserts the table names exactly its retained arms, so
the two cannot drift. The same table renders
[simulator-only strategies](/docs/trader/strategies/simulator-only).
## The `rhai` arm [#the-rhai-arm]
`rhai` resolves here and is deliberately **absent** from `STRATEGIES`: it requires a `src` param —
the inline script source — so it is not default-resolvable, and listing it would break every
consumer that enumerates the roster and resolves each name with empty params. A missing `src` is
a fail-fast validation error; every other numeric param is a `param(name, default)` override,
the same knobs a `[sweep]` varies, baked into the script scope at compile through
`RhaiStrategy::compile_with_params`. A Rhai parse or compile error surfaces as a validation error
at load, not a mid-backtest panic. Integers and floats are both accepted for a numeric knob, so
`size = 1` reads the same as `size = 1.0`.
`vike_strategy::registry`'s `SCRIPT_ONLY` classifies the *name*, not the script path. The daemon
links `vike-script` directly and mounts a script by path, `[strategy] rhai = ""`, never
through this registry; the inline-`src` arm here remains the backtest spelling. That scripts mount
live at all is a recorded architecture verdict, and the `SCRIPT_ONLY` row carries it as the reason
a refusal on the *name* redirects to the path spelling rather than claiming the strategy does not
exist. See [writing a strategy](/docs/trader/scripting/writing-a-strategy).
## User strategies, consulted last [#user-strategies-consulted-last]
When neither half knows a name, the resolver consults the user registry —
`vike_user_strategies::user_strategy_by_name`, compiled from `user_data/strategies/rust` and empty
in any checkout without one — before giving up. Built-in arms are tried first, so a user folder
can never shadow a registry name. The final error names both rosters, the built-ins and the
user's, so an operator can tell "not compiled in" from "typo'd a built-in". Authoring one is
[authoring in Rust](/docs/trader/strategies/authoring-rust).
The five reference strategies are `#[derive(Default)]` with no harness-tunable knobs today —
`Default::default()` is their documented constructor. `cheap_catch_updown_fair_value` and
`sport_copy_follower` are genuinely param-driven and read their tables the way `buy_hold` does.
# Backtesting (/docs/trader/backtesting)
Vike Trader's backtester is the `vike-backtest` crate, and it offers exactly two simulation APIs.
`crates/vike-backtest/src/lib.rs`'s module header states the unification: `engine` holds **the one
event engine** (`StrategyEngine` over `SimBroker`, any number of symbols) and `vector_engine` holds
**the one vectorized kernel** (`VectorBacktestEngine`, any number of symbols); the older
`SingleSymbolEngine` and `fast_backtest` are retired. Both return the same `BacktestResult`, which
lives one crate down in `vike-analytics` and is re-exported here — the numbers are the same type
whichever engine produced them.
## The two engines, and which to reach for [#the-two-engines-and-which-to-reach-for]
**The event engine** is the general one. It steps aligned per-symbol bar series timestamp by
timestamp — or, in tick mode, k-way-merges recorded quote, trade and book events — and hands each
event to a strategy written against `vike_model`'s `Strategy` trait, the same trait that
runs live (`crates/vike-model/src/strategy/mod.rs`'s `Strategy`, whose `on_bar` doc
names both firing points: once per symbol per step in a backtest, once per closed bar of the
mounted series live). Everything with a resting order, a stop, a bracket,
a cash gate, a margin call, a sizer or a maker quote goes here. Read [the portfolio
engine](/docs/trader/backtesting/portfolio-engine) for the step loop and [the sim
broker](/docs/trader/backtesting/sim-broker) for the fill engine it drives.
**The vectorized kernel** is deliberately narrow. `crates/vike-backtest/src/vector_engine.rs`'s
header draws the boundary: the `cash_gate=False`, market-order, target-weight path only —
next-open fills, column-order shared-cash application, taker fees, slippage, funding, cost-basis
averaging — and names what is out of scope: stops and brackets, the cash gate, volume and
position caps, leverage capping, membership masks, liquidation, sub-bars and sizers. If your
strategy is a T×S matrix of target weights and nothing else, it is the fast path. Read [the vector
engine](/docs/trader/backtesting/vector-engine).
The two are reconciliation partners, not alternatives that may drift: engine-versus-kernel
agreement is a second oracle, gated as a test in `crates/vike-backtest/tests/parity/engine_kernel_parity.rs`,
whose header requires the same trade count and a bit-identical final equity, and says never to
widen that to a tolerance.
## What a fill is, defined once [#what-a-fill-is-defined-once]
Neither engine owns its cost model. `crates/vike-fills/src/lib.rs` is the shared fill cluster —
`broker_sim` (the three canonical cost scalars), `fill_model` (the bar, L1 tick and L2 book-walk
tiers), `fill_resolution` (adverse-first intrabar ordering with the SL/TP bracket cap) and
`staleness` (the opt-in stale-price wait) — and `vike-backtest` re-exports all four at its root.
The reason it is a separate crate is the reason it matters to a reader of a report: the paper
exchange mounted on a live feed must charge exactly what `SimBroker` charges, or "backtest ==
paper == live" stops being checkable. The primitives are documented under [fill
models](/docs/trader/backtesting/fill-models), [fill resolution](/docs/trader/backtesting/fill-resolution),
[the cost model](/docs/trader/backtesting/cost-model) and [staleness](/docs/trader/backtesting/staleness);
the equivalence they buy is [the paper exchange](/docs/trader/concepts/paper-exchange).
The arithmetic is held to the parity rules `lib.rs` restates from `vike-model`: `f64` end to end,
the same expression order, no `mul_add` or fast-math, and Python floor-mod on timestamps. That is
what lets [the parity gate](/docs/trader/concepts/parity-gate) compare bit patterns rather than
tolerances.
## The harness: from a TOML file to a result [#the-harness-from-a-toml-file-to-a-result]
Nobody constructs an `EngineParams` by hand to run a backtest. `crates/vike-backtest/src/harness/mod.rs`
is the config-driven end-to-end runner: `profile` parses and validates a `BacktestProfile`,
`registry` resolves `strategy.name` to a boxed strategy over `SimBroker`, `run` dispatches the
profile to bar mode (`StrategyEngine`) or tick mode (the `hist_replay` loader), and `report`
composes the metrics into a printable `BacktestReport`. The multi-run siblings — `sweep`,
`walkforward`, `euler` and `tpe` — all take the same `(&BacktestProfile, Arc)`
shape and read their own profile section.
The harness is gated behind the `hist-replay` feature and compiles against the `HistStore`
**trait** only; the concrete DataFusion store that the `backtest` binary actually opens sits
behind a separate `datafusion-store` feature (`crates/vike-backtest/Cargo.toml`'s `hist-replay`
and `datafusion-store` entries state both halves). Depending on the
harness therefore does not pull the Arrow tree, which is what lets `vike-datahub`'s default build
be DataFusion-free — a trait-only library, with the real server binary behind its own feature.
Start with [run profiles](/docs/trader/backtesting/run-profiles) — the TOML is the whole user-facing
surface — then [running a backtest](/docs/trader/backtesting/running) for the two ways a profile is
executed, [the harness registry](/docs/trader/backtesting/harness-registry) for what `strategy.name`
may say, and [runs on disk](/docs/trader/backtesting/runs-on-disk) for what a finished run leaves
behind. The engine pages after that explain the number you got.
## The rest of the tree [#the-rest-of-the-tree]
The opt-in realism knobs each have a page: [the queue lane](/docs/trader/backtesting/queue-lane) and
[queue models](/docs/trader/backtesting/queue-models) for resting maker limits, [market
impact](/docs/trader/backtesting/market-impact) for bar mode, [latency models](/docs/trader/backtesting/latency-models)
for the tick path, [tick replay](/docs/trader/backtesting/tick-replay) for how store rows become a
replay stream, [timeframes](/docs/trader/backtesting/timeframes) and [scheduling](/docs/trader/backtesting/scheduling)
for the strategy-facing clock. Search over a profile's grid is [sweeps](/docs/trader/backtesting/sweeps),
[search](/docs/trader/backtesting/search) and [objectives](/docs/trader/backtesting/objectives);
out-of-sample stitching is [walk-forward](/docs/trader/backtesting/walk-forward). The statistics a
report is made of live under [analytics](/docs/trader/analytics).
# Latency models (/docs/trader/backtesting/latency-models)
Without a latency model a simulated order is submitted, matched and cancelled with **zero**
delay. `crates/vike-backtest/src/latency.rs` states the consequence in its header: the strategy
sees a tick and its order is already resting at the matching engine on that same tick, and a
cancel issued on the tick that would have filled it always wins. That gives a maker impossible
reflexes and is, the header says, the single largest source of optimism left in the replay path
after the [queue model](/docs/trader/backtesting/queue-models), its natural partner.
It is opt-in and replay-only: `EngineParams::latency_model = Some(kind)` arms it, `None` (the
default) means no in-flight queue is ever built and every code path is byte-identical, and only
`StrategyEngine::run_ticks` arms it — `SimBroker::latency`'s doc says the gate is installed for
the duration of a tick replay and taken out at the end, which is what structurally guarantees the
bar engine and the vector kernel can never consult one.
## Two legs [#two-legs]
* **Entry latency** — a local submit at `T` becomes visible to the matching logic at
`T + entry()`. Everything the strategy sends — new orders, cancels, amends — is held in an
in-flight queue keyed by that delivery timestamp.
* **Response latency** — an exchange-side event at `exch_ts` reaches the strategy at
`exch_ts + response()`. Fills are delivered to `Strategy::on_fill` late.
Both are signed nanoseconds, while the engine's own timestamps are epoch **milliseconds**. The
wire-in keys its queue in nanoseconds (`ts_ms × 1 000 000 + latency_ns`) so a sub-millisecond
latency is not silently truncated; it simply means the action lands on the next tick whose
millisecond stamp has caught up. A **negative** entry latency encodes a recorded rejection: the
request never reached the matching engine, so the engine drops the action outright and records it
in `SimBroker::dropped` under `"latency_reject"` — the channel the
[zero-trade analyzer](/docs/trader/analytics/sizing-and-zero-trade) reads, so a calibrated series
with a rejection burst cannot silently swallow a maker's orders and leave "why did my maker stop
quoting" undiagnosable.
## The two shipped models [#the-two-shipped-models]
`LatencyModel` is the seam — `entry(ts, order)` and `response(ts, order)`, pure functions with
no I/O, no clock and no interior mutation, so a replay is reproducible. `LatencyOrder` is the
deliberately tiny descriptor a model may condition on: side, quantity, optional price.
**`ConstantLatency`** is fixed nanoseconds on both legs, the calibration-free baseline. Its doc
draws a distinction worth keeping: `entry_ns: 0` / `response_ns: 0` reproduces zero-latency
behaviour while still exercising the in-flight queue, so it is **not** the same thing as
`latency_model: None` — an action submitted at `T` still applies only at the next drain, the
next tick at `ts >= T`.
**`IntpOrderLatency`** interpolates from a recorded series of round trips —
`LatencyRow { req_ts, exch_ts, resp_ts }` in epoch nanoseconds, `exch_ts <= 0` marking a
rejection. Lookup is a binary search for the bracketing pair and a linear interpolation between
them, along a different abscissa per leg: the entry leg in `req_ts` over every row, the response
leg in `exch_ts` over a second index with rejection rows excluded. Outside the series the nearest
end row's latency is used, never an extrapolation, and rejections are not interpolated across —
if either bracketing row is one, the nearer row's latency is returned verbatim, so a recorded
rejection is never blended into a merely slow accept. An empty series is inert.
## The in-flight queue [#the-in-flight-queue]
`LatencyGate` holds the built model and the queue. Every price tick drains everything whose
delivery stamp has arrived, so the queue only ever holds the actions issued within one latency
window; the due slice is sorted by `(delivery_ns, seq)`, where `seq` is a monotonic submission
counter that keeps a batch sharing one delivery stamp in the order the strategy issued it — a
cancel then a re-submit must not swap. `InFlight::seq`'s doc names the precondition: both shipped
models ignore the order descriptor, so that holds; a user model that gives a bare cancel a
different entry latency than the sized re-submit after it can legitimately reorder the pair, and
a maker whose requote's cancel lands last is left with its tag pulled rather than re-priced.
There is deliberately no end-of-run flush of the order queue: an action undelivered when the
tape ends never reached the venue and must not retroactively apply. The **response** leg is
flushed — `flush_deferred_fills` delivers every still-held fill, because a backtest must not end
with fills the strategy was never told about.
Only the strategy's **view** is delayed, never the exchange's own state. `SimBroker::apply_fill`
folds a fill into the position, cash and the equity curve the instant it matches, and
`BacktestResult` is computed from that truth. What the response leg holds back is the
`Strategy::on_fill` callback and the strategy-visible **shadow position** — a per-symbol signed
size advanced only by delivered fills, which `Broker::position` and `HftBroker::position` read
while the gate is armed. `SimBroker::shadow_pos`'s doc says why it exists: a maker that polls its
inventory rather than accumulating it in `on_fill` used to see every fill with zero latency,
which made the leg inert for the [spread maker](/docs/trader/market-making), whose inventory skew and
reservation price are driven by exactly that poll.
Still bypassing the model, by design: `SimBroker::position_of`, `pending_of`, `equity_now` /
`Broker::equity`, `drawdown_now` and the target-percent verbs read exchange truth
synchronously — they are the accounting surface, and shadowing them would make `on_stop` disagree
with the result. A strategy that sizes off `equity()` still reacts to a fill with zero response
latency.
An order in flight is invisible to `pending_of` until delivered. `SimBroker::in_flight_of(symbol)`
is the read that sees it — always `0` when the gate is not armed — so a strategy checks
`pending_of(s).is_empty() && in_flight_of(s) == 0` rather than re-firing a duplicate on every
tick of the entry-latency window. The pre-trade leverage cap does count in-flight market orders
(`LatencyGate::in_flight_market_signed`): a local risk check must model the local view, or every
submit inside one latency window would be granted the full leverage room in exactly the regime
the model exists to make pessimistic.
## Venue-enforced holds [#venue-enforced-holds]
Some delays are venue rules, not network estimates. `VENUE_HOLD_POLYMARKET_UPDOWN_MS` is 250 ms,
verified against the venue's published order-lifecycle documentation on 2026-07-23: on exactly
the crypto up/down markets the venue holds the order, validates it again, and only then matches
or books it. `VENUE_HOLD_POLYMARKET_SPORTS_GAME_MS` is 3 000 ms, an independent mechanism
measured live the same day — 400 of 400 open sports markets carrying a `game_start_time`
declared `seconds_delay: 3` — and its doc warns it is not universal, a later sweep having found
the esports book declaring 1 000 ms. Both literals are owned by `vike_model::venue_hold`.
`LatencyModelKind::venue_hold_ms(ms)` models a hold on the entry leg only — a hold delays when
the venue acts, not when the strategy hears about it — and a deployment pays it **on top of** its
wire round trip. Better than a constant, since the hold is a per-market property and one tape
can carry both kinds: `SimBroker::taker_hold_table_ns` resolves each symbol's
`SymbolProperties::taker_hold_ms` once at run start through the same point-in-time
[properties](/docs/trader/concepts/instruments/symbol-properties) seam the fill grid uses, and
`LatencyGate::submit` adds it to the model's entry leg; an empty or all-zero table is
byte-identical to no table. Paired with the [L2 fill model](/docs/trader/backtesting/fill-models), the
venue's "matched or booked" second validation falls out: decided on the book at `T`, delivered
at `T + hold`, matched against the book as it then stands, resting if it no longer crosses.
## In a profile [#in-a-profile]
A [run profile](/docs/trader/backtesting/run-profiles) exposes the constant model as two millisecond
knobs in `[engine]`, tick mode only: `order_latency_ms` is the entry leg on every order action —
place, modify, cancel — and `fill_latency_ms` the response leg before the strategy learns of a
fill. Either `> 0` builds a `LatencyModelKind::constant`; both `0` is `None`, byte-identical. A
recorded series is reached through `EngineParams` directly.
# Market impact (/docs/trader/backtesting/market-impact)
The frozen cost model applies one **flat** adverse move per fill: a 5 bp `slippage` costs the
same whether the order is one share or 10 % of the day's volume. `crates/vike-backtest/src/impact.rs`
opens by naming the direction of that error — it flatters a backtest, because size is exactly
what a real book charges for — and supplies the missing size-dependent cost for the lanes where
**no book exists**, estimated from bar OHLCV alone. Where L2 depth is recorded the engine already
does better than any model — `run_ticks` walks the real replayed book and
[the queue lane](/docs/trader/backtesting/queue-lane) gates resting makers — which is why a
[run profile](/docs/trader/backtesting/run-profiles) that sets `[engine.impact]` in tick mode is
rejected at validation rather than ignored.
It is opt-in through `EngineParams::impact = Some(model)`. With `None` the wire-in site,
`SimBroker::slippage_for`, returns the flat `slippage` field unchanged with no arithmetic
performed on it, so every parity and golden fixture is untouched. With a model, the estimate is
**added** to `slippage` for each taker fill and the sum goes to `adverse_fill_price` —
[the cost model](/docs/trader/backtesting/cost-model).
## Taker-only [#taker-only]
The estimate is charged on aggressive fills only. A resting limit that was hit **supplied** the
liquidity this cost pays for, so `slippage_for` returns the flat `slippage` alone for maker
fills — charging otherwise would execute a maker through its own limit price. The gate lives at
the wire-in site, not in the models, so every `ImpactModel` inherits it; `EngineParams::impact`'s
doc draws the consequence that the knob is safe to leave on for a maker strategy: it prices the
aggressive legs and leaves the passive ones alone.
## The model [#the-model]
`AlmgrenChriss` ports the published cost function of Almgren, Thum, Hauptmann and Li (2005),
*Direct Estimation of Equity Market Impact* — an empirical calibration fit to roughly 700 k US
equity orders, which is why its coefficients are quoted rather than tuned. Two additive terms,
both a fraction of price:
```text
nu = qty / (exec_time * avg_volume) -- participation rate
permanent = gamma * sigma * (nu * exec_time)^alpha == gamma * sigma * (qty/avg_volume)^alpha
temporary = eta * sigma * nu^beta
impact = permanent + temporary
```
with the published exponents `alpha = 0.891`, `beta = 0.600` and coefficients `gamma = 0.314`,
`eta = 0.142` (`AC_ALPHA`, `AC_BETA`, `AC_GAMMA`, `AC_ETA`). Permanent impact is the shift the
order leaves in the market after it is done; temporary impact is the concession paid for
demanding liquidity now, and decays once trading stops. The header flags where `exec_time` does
and does not appear, because it is the term a transcription slip reaches for: the permanent term
is a function of the total fraction of volume traded and is **horizon-independent** — working an
order more slowly does not change the footprint it leaves — while only the temporary term sees
the horizon, through the participation rate.
One term of the paper is deliberately omitted: the fundamental-data liquidity adjustment on the
temporary term, `(shares_outstanding / avg_volume)^delta` with `delta = 0.267` (`AC_DELTA`,
recorded for provenance and read by nothing). It needs a per-issuer shares-outstanding series
this workspace has no source for and that does not exist at all for crypto and perp symbols; its
absence makes the temporary term the paper's value at the calibration sample's median liquidity.
The published coefficients are calibrated on **daily** units: `sigma` is a daily return
volatility and `avg_volume` a daily share volume, so `exec_time` is measured in days.
`window_stats` derives both from the bar series being backtested, which means the calibration is
literally right only on daily bars. On finer bars the shape — monotone in size, concave in
participation — still holds, but the absolute level is a per-bar quantity: treat it as a tunable
cost, not a published estimate, and scale `exec_time` accordingly.
## The market context [#the-market-context]
`ImpactInputs` is what one fill is priced against: `qty`, an absolute order size in the same unit
as `avg_volume`; `avg_volume`, the average traded volume per period; `sigma`, a fractional return
volatility over that same period. `window_stats(bars, window)` measures the last two over the
last `window` bars — the sample standard deviation of simple returns and the mean volume, both
summed through `py_sum` so the result is independent of how the window was sliced — and returns
`None` when the window cannot support an estimate: fewer than three bars, a non-positive close,
or non-positive mean volume. `DEFAULT_IMPACT_WINDOW` is 21 bars, one trading month of daily
bars: long enough that a single outlier cannot dominate the volatility estimate, short enough to
track a regime change.
`slippage_for`'s doc pins the window free of lookahead on all three bar lanes: the context is
measured over the bars that **closed before** the fill's timestamp, excluding the bar the
timestamp falls inside, because its own close and volume are not knowable at the moment the
order prices and its volume would in part be this very order. A window too short or degenerate
to measure charges the flat slippage alone rather than guessing.
## What a model must promise, and what it need not [#what-a-model-must-promise-and-what-it-need-not]
`ImpactModel::impact_frac` must return a finite, non-negative fraction, non-decreasing in `qty` —
bigger orders never cost less — and a model with insufficient or degenerate inputs must return
`0.0` rather than a NaN or infinity that would poison the equity curve; `AlmgrenChriss` charges
nothing on a non-positive or non-finite sigma or a participation rate the inputs cannot support.
No upper bound is required, and the trait doc calls that a decision rather than an omission. A
fraction of `1.0` means the move ate the whole price, and past it the multiplicative haircut
inverts the sign — but a model cannot see the two things that decide whether that happens: the
flat `slippage` it is added to (a per-model cap of `1.0` would still let `0.5 + 0.9` cross) and
the side the engine will point the move in. The bound lives where both operands are in hand,
`adverse_fill_price`'s `MIN_ADVERSE_FACTOR`, and the fill site counts each saturation in
`SimBroker::slippage_saturations`.
Both `^` in the formula are `libm::pow`, never `f64::powf`: base and exponent are arbitrary
runtime `f64`s, the platform's libm decides the last bit of such a power, and the number is
multiplied into a fill price — two boxes replaying one tape must not report different PnL.
## In a profile [#in-a-profile]
```toml
[engine.impact]
model = "almgren_chriss" # the only model name today
exec_time = 1.0 # execution horizon, in bar-periods
window = 21 # rolling sigma/volume lookback, in bars
```
`ImpactCfg::build` fails the profile on an unknown model name, a non-positive or non-finite
`exec_time`, or a `window` under 3 — a typo must fail loudly rather than silently price fills at
zero impact. The [vector engine](/docs/trader/backtesting/vector-engine) has no per-fill market
context and never consults the model, so engine-versus-kernel reconciliation is meaningful only
with `impact: None`, which is what the parity gate runs. This is the *backtest* cost estimate;
the live pre-trade **veto** against displayed liquidity is a different primitive,
`vike_model::impact`, which walks a real book and denies rather than prices.
# Objectives (/docs/trader/backtesting/objectives)
Every ranked search — the grid, successive halving, TPE — ends by sorting rows on one number. `crates/vike-backtest/src/objective.rs` defines that number: an `Objective` is a boxed `Fn(&BacktestReport) -> f64`, higher is better, and `run_sweep_with` ranks a sweep's rows by any of them, stamping each successful row's `score`. The module is feature-free by design, like the report it scores: nothing in it names the gated profile parser or the DataFusion tree, so leaf crates reuse the same objectives and its tests run in the default lane.
## The built-in objectives [#the-built-in-objectives]
The four classic `--rank-by` metrics are exposed as objective constructors through `RankMetric::objective` in `crates/vike-backtest/src/harness/sweep.rs`, with direction folded in — `MaxDrawdown` becomes `−max_drawdown`, the other three pass through — so ranking by `Sharpe.objective()` through `run_sweep_with` orders rows exactly as the classic comparator does, `NaN`-last included; `metric_objectives_rank_identically_to_cmp_reports` gates it. The classic `run_sweep` keeps its own comparator untouched, so the default output stays byte-identical and a `SweepReport`'s `rank_by` serialises as the same bare metric string; an objective-ranked report carries its label instead, e.g. `"multi"`.
## Two laws [#two-laws]
Both are property-tested, because a sweep's first row feeds downstream selection and "degenerate points cannot be crowned" has to be provable rather than intended.
**`NaN` is unrankable.** A `NaN` score is legal and means the row cannot be ranked; `cmp_scores_desc` — the one best-first comparator every score-ranked sort in and out of the crate goes through — puts it after every finite score, and `sort_scored_rows` puts rows with no score at all (failures) last, stably. Every `multi_metric` input that is `NaN` poisons the score rather than being substituted, and a non-finite score serialises as `null`.
**The sign law.** Every losing run scores strictly below every profitable one, whatever its other statistics say. Only `multi_metric` can violate it, so only it is bound by it: its shaping terms are factors that mostly sit under 1, and applied multiplicatively to a negative base they would shrink a loss toward zero — reward it — and rank the most catastrophic point first on an all-losing grid, the common case when tuning a bad strategy.
## The multi-metric score [#the-multi-metric-score]
`multi_metric(params)` builds the composite objective; `--rank-by multi` uses the default `MultiMetricParams`. The formula, as the type's doc states it:
```text
base = total_return - max_drawdown
shape = ln(1 + profit_factor) // profit_factor clamped to profit_factor_cap
* winrate_coeff // winrate_floor + (1 - winrate_floor) * win_rate
* trade_count_penalty(n_trades)
-> clamped into [0, shape_max], shape_max = ln(1 + profit_factor_cap)
quality = shape / shape_max
score = if base > 0 { base * shape }
else { base * (1 + loss_quality_weight * (1 - quality)) }
```
So the zero point means "did nothing": above it a quality-shaped profit, below it a quality-amplified loss, and exactly zero a breakeven, no-trade or no-winner run there is nothing to rank. The defaults: `target_trades` 50 (the statistical-significance knee, at and above which `trade_count_penalty` is exactly 1.0 and below which it falls linearly), `penalty_floor` 0.1 (so a thin profitable point stays visible), `winrate_floor` 0.0, `profit_factor_cap` 100.0 and `loss_quality_weight` 1.0 — the worst-quality loss counts double, which keeps loss size the dominant term; only losses within 2× of each other can be reordered by quality, `0.0` disables the shaping, and a negative weight is treated as zero because it could flip a loss positive. The cap exists because `metrics::profit_factor` returns the house `f64::INFINITY` sentinel for a run with no losing trades — unclamped, every all-win point would score infinite and rank first — and `ln(101) ≈ 4.6` leaves any realistic profit factor untouched.
Under the default `winrate_floor` of 0.0 the coefficient is the plain win rate, so a profitable run with no winning trade collapses onto the zero plateau and a winnerless loser takes the full amplification. Raise the floor above zero to keep the profitable regime strictly ordered, or to soften the term for a low-win-rate, high-payoff style.
The clamp on `shape` is what makes the sign law hold by construction rather than by trusting the report's provenance: a hand-built report or an exotic floor could otherwise make `shape` negative and flip a profitable run below a losing one, and `f64::clamp` passes `NaN` through unchanged so a `NaN` metric still poisons the score instead of being laundered into a number.
## Why the logarithm is not `f64::ln` [#why-the-logarithm-is-not-f64ln]
Every `ln` in the module is `libm::log`. IEEE 754 requires `+ − × ÷` and `sqrt` correctly rounded and requires nothing of `ln`, so the method spelling resolves to whatever libm the platform ships, and two platforms differ in the last bit. Here that decides a ranking: `shape_max` is the normaliser of the losing regime's quality term and the upper clamp of `shape`, so two boxes could order two near-tied rows differently and crown a different winning config off the same data. `libm` is one pure-Rust implementation compiled into the binary, so both boxes run the same code. What the module deliberately does not claim: this is not a live order path and not even the sweep's default ranking — `multi` is opt-in, and with the flag absent the binary takes `RankMetric::default()`, which is Sharpe, and nothing in the module is called. The exposure is a research one, and worth closing for exactly that reason rather than by inflating it into a trading hazard.
The metrics themselves are on [metrics](/docs/trader/analytics/metrics) and [the report](/docs/trader/analytics/report); [overfitting](/docs/trader/analytics/overfitting) is what a crowned row is tested against next.
# The portfolio engine (/docs/trader/backtesting/portfolio-engine)
`crates/vike-backtest/src/engine.rs`'s `StrategyEngine` is multi-asset backtesting on one shared
cash account — an exact port of the Python `core/multi_symbol_engine.py::StrategyEngine`, kept
line-for-line so that parity beats idiom. Aligned per-symbol bar series are stepped timestamp by
timestamp; an order submitted during a step fills at each symbol's **next** bar open, so there is
no look-ahead; equity is `cash + Σ pos·price·mult`.
The engine is generic over `S: Strategy`. Handlers receive `&mut SimBroker` — the
context every verb and read lives on ([the sim broker](/docs/trader/backtesting/sim-broker)) — and the
fill dispatchers are engine methods rather than broker methods so that `on_fill` fires
synchronously after each applied fill, the Python engine's exact firing point.
## Construction [#construction]
`StrategyEngine::new` asserts every series has the same length — they must be aligned — and
pre-tags each bar's `symbol` once with `format_instrument(default_venue, symbol)`: `SYMBOL.VENUE`
when a venue is set, the bare symbol otherwise. Per symbol it builds the higher-timeframe
aggregates named in `timeframes` ([timeframes](/docs/trader/backtesting/timeframes)), buckets granular
sub-bars into their coarse step, resolves the contract multiplier once so hot paths index a vector
rather than scanning by string, resolves the session calendar if the gate is on, and mounts the
pre-trade risk gate. The queue tracker and the latency model are created here and consulted only
by the tick path.
## One step of the bar loop [#one-step-of-the-bar-loop]
`StrategyEngine::run` executes the same sequence for every bar index:
1. **Terminal settlements.** The opt-in binary-resolution and option-expiry checks run before
anything can fill, so a symbol that resolved on this step can neither fill resting orders nor
keep a position past resolution.
2. **Freshness bookkeeping**, when the stale-price wait is configured, so a bar that is itself a
fresh print has age zero and fills on the spot.
3. **Protective stops**, per symbol, against the bar's open/low/high — except a symbol whose
sub-bars drive its own fill pass this step; its stops are checked per sub-bar inside step 4
instead. A breach closes at the shared trigger oracle's price — a bar that gaps through the stop
fills at the gapped open — and cancels the fired stop's OCO sibling exits, on every lane.
4. **The fill phase.** With the cash gate off, each symbol runs its own pass — `fill_pending`, or
`fill_pending_granular` when sub-bars exist for this step. With it on, `fill_step_gated` runs
across all symbols: reductions and closes fill first and are never gated, opens and adds fill in
descending weight order (ties by trigger order), and an order the shared cash cannot fund is
dropped and recorded.
5. **Price, funding and excursions.** The last price becomes the close; a held position is charged
funding when a rate exists — a recorded `Bar.funding` wins, else the opt-in funding source — and
the running high and low since entry advance for MAE/MFE.
6. **Variation-margin settlement** when a cadence is configured, then the forced close at the open
of any symbol that left the active mask, then the **margin call**.
7. **The strategy.** Past warmup, `on_bar` fires per symbol in symbols order, then any due
scheduled callbacks ([scheduling](/docs/trader/backtesting/scheduling)).
8. **The equity sample**: one per bar, plus a per-symbol realized-plus-unrealized curve.
Every untagged fill lane runs one per-order gate sequence through `gate_pending` — session,
staleness, the emulator stop-release, then the trigger price. Each lane used to spell those calls
inline, which is why the two wait disciplines had to be written five times and kept in sync by
hand. Orders that trigger together on one bar are ordered by the shared adverse-first resolver
([fill resolution](/docs/trader/backtesting/fill-resolution)).
After the loop, an end-of-run resolution sweep and option-expiry sweep run once — a real
prediction-market tape stops at the trading halt and the resolution posts later, so without the
sweep a position held to expiry would be marked at its last traded price instead of the payout —
then `on_stop`, then the `BacktestResult`.
With no explicit `resolution_end_ts`, the end-of-run sweep asks the resolution source "did this
market ever resolve?" at `RESOLUTION_PROBE_SENTINEL`, which is `i64::MAX / 4` rather than
`i64::MAX`. The source is caller code, and the natural way to write one does windowed arithmetic
on the probe timestamp; with `i64::MAX` any addition overflows — a debug panic, a silently wrong
wrap in release. The sentinel sits about 2.3 million years past the epoch: after every real
resolution, with three quarters of the range left as headroom.
## Liquidation [#liquidation]
`check_liquidation` runs when `maint_margin` is positive, judging the whole account at the bar's
intrabar adverse marks. The default is the shared scope-parameterized law in
`vike_model::liquidation` — the same `pool_breached` and `cross_liquidation_plan` the live watchdog
runs: breach when maintenance exceeds pool equity by the `liq_buffer` grace line (default `0.10`,
the watchdog's own buffer), then close the biggest losers first, only the excess over the line,
stopping once healthy. That is a real behaviour change for margined backtests, which previously
wiped the whole account at the bare trigger; the old model is preserved bit-for-bit behind the
opt-in `venue_style_liquidation` stress knob. The live half is [margin
calls](/docs/trader/concepts/margin-call).
## The tick path [#the-tick-path]
`StrategyEngine::run_ticks` replays recorded events: a stable k-way merge by timestamp across the
per-symbol streams, ties broken by stream order, each tick routed to its symbol slot by name.
`on_bar` does **not** fire — the strategy sees `on_quote_tick`, `on_trade_tick` and
`on_order_book`. A recorded book event folds into the symbol's replay book under the shared
`L2Book::delta_decision` law with strict sequencing: a delta applies only when it is the next
sequence number, and a gap, an orphan or a recorded gap marker drops the book until the next
snapshot re-anchors it — identical to what the live consumer knew at the time. Book events advance
the clock and settle a just-resolved symbol but never fill or bump the warmup index; quotes and
trades own price semantics. How store rows become that stream is [tick
replay](/docs/trader/backtesting/tick-replay).
The tick path is the only place the latency model is armed — `run_ticks` installs the gate for
the replay and removes it afterwards, so the bar path and the vector kernel structurally cannot
consult one ([latency models](/docs/trader/backtesting/latency-models)) — the only lane that consults
the queue tracker ([the queue lane](/docs/trader/backtesting/queue-lane)), and the only one that honours
`EquitySampling`. It takes two equity samples per tick on purpose: the peak is stamped before the
strategy callback so a `drawdown_now` read inside it already includes this tick's mark, and the
curve after, because `SimBroker` exposes `cash` and `sym` as public fields and a strategy can move
equity in-callback.
## What comes out [#what-comes-out]
`BacktestResult` carries the trade list and count, the equity curve and its timestamps, final
equity, per-symbol PnL and curves, the strategy's warmup, net funding paid, and the diagnostics
the broker accumulated: `dropped` (every refused fill with its reason), `intrabar_both_hit`,
`stale_deferrals`, `session_deferrals` and `below_min_reversals`. The zero-trade analyzer reads
the first of those to explain why orders did not fill ([sizing and zero-trade
diagnosis](/docs/trader/analytics/sizing-and-zero-trade)).
# The queue lane (/docs/trader/backtesting/queue-lane)
The frozen tick-replay fill pass fills a resting limit the instant price touches it. The queue
lane is the opt-in alternative: the same pass, with every resting limit — a tagged maker quote
included — additionally gated by an estimate of how much size sits ahead of it at its price.
`crates/vike-backtest/src/engine/queued.rs` is the lane; the estimators it consults are
[queue models](/docs/trader/backtesting/queue-models), and this page is about the lane rather than
the arithmetic.
## When it runs [#when-it-runs]
The lane is reached **only** when `EngineParams::queue_model` is `Some`. Its module header
states the contract the rest of this page rests on: a default run never enters the module at
all, and the frozen `fill_pending_tick` / `fill_tagged` lanes it mirrors stay byte-identical.
From a [run profile](/docs/trader/backtesting/run-profiles) that is `queue_model = "…"` in `[engine]`,
which `EngineCfg::queue_model`'s doc marks **tick mode only** — the lane is consulted exclusively
by `run_ticks`, so a bar-mode profile that sets it is rejected by `BacktestProfile::validate`
rather than silently doing nothing.
The module is a separate file for a reason its header gives: the queued twins used to sit some
700 lines from the code they mirror, in a 2 200-line file — a hand-synced duplication no reader
could check without scrolling between two ends of the file.
## What is shared and what is genuinely different [#what-is-shared-and-what-is-genuinely-different]
The per-order gate sequence is not duplicated here. Session → staleness → emulator stop-release
→ trigger price lives in one function on the parent module, `StrategyEngine::gate_pending`,
which this lane's **taker** branch calls like every other lane; the maker-lane session gate lives
in `StrategyEngine::defer_tagged_session`. What the header lists as staying in `queued.rs` is
only what makes the twin a twin: queue-state identity and sync (`WorkingOrder::qid`), the
`queue_gate` verdict layered on top of the frozen price law, partial-fill remainder bookkeeping,
and the maker straddle and min-hold guards. The one gate the lane still runs by hand is
`StrategyEngine::defer_session`, on its price-conditional branch.
`fill_pending_tick_queued`'s doc gives the shape: the same structure as the frozen path —
trigger → resolve → dispatch → tagged lane — with resting `Limit` / `LimitClose` orders
additionally gated by their queue-position estimate. Non-limit kinds (market, market-close,
stop, trailing) are takers and pass through unchanged: a taker crosses the book, it never rests
in it.
## The verdict [#the-verdict]
`queue_gate` judges one resting limit against the **raw** tick, because the derived `Bar` shape
cannot distinguish a trade from a quote. It is computed only after the existing fill model has
already said the price condition holds — the gate composes on top of the frozen price logic and
never replaces it. Three outcomes, `QueueVerdict::{Rest, Fill, Partial}`:
* a **strict price cross** — traded or quoted *through* the level — fills in full, because the
whole level, queue ahead included, was consumed;
* a **trade at the price** consumes the front first, and only its excess beyond the front
fills — in full when that excess covers the whole order, partially otherwise, the remainder
resting;
* a **quote touch** fills only once the front is already cleared — there is no trade proof
otherwise.
Book events never reach the price/fill path, so a `Tick::Book` always rests. Two simplifications
are declared on the function rather than hidden: trades are not filtered by aggressor side, and
simulated orders never consume each other's queue — each order is gated independently, the
standard shadow-order convention.
## Identity, not fingerprints [#identity-not-fingerprints]
Queue state is keyed by the order's engine-local identity, `WorkingOrder::qid`, stamped the
first time the lane sees an order — never by a `(side, price)` fingerprint. The doc gives the
case that rule exists for: the maker re-quote pattern `cancel_all` + re-submit at the same price
correctly re-seeds at the **back** of the level instead of inheriting the cancelled order's
advanced front. States whose order vanished since the last pass — `cancel_all`, an OCO
sibling-cancel, a fill — are dropped by that pass's rebuild.
The tagged (HFT maker) lane, `fill_tagged_queued`, is the queue-gated twin of `fill_tagged` and
keys differently, because a tag names a quote **slot**, not a resting order: its sync keys on the
tag plus the slot's current side, price and `qid`. Its doc lists what forfeits priority — a
re-priced tag, a re-submit over a live tag (`submit_limit_tagged` replaces the resting order), a
`cancel_tagged` + re-submit, and a `modify_tagged` quantity **increase** all re-seed at the back
of the level, each being a new order on a real venue. An amend-down keeps its place, as venues
do. Gone tags drop their state; partial fills shrink the resting tag in place instead of retiring
it.
## The maker guards [#the-maker-guards]
The min-hold guard is `EngineParams::queue_min_hold_ms` (`queue_min_hold_ms` in the profile,
read only when `queue_model` is set): on the **tagged** maker lane, a fill that would **reduce**
the current position — the closing side of a maker round-trip — is deferred until at least that
long after the position was opened, so a maker cannot book a physically impossible
same-millisecond or sub-second round-trip. `0`, the default, disables it, and the untagged
queued-limit lane runs no such check at any setting. The field's doc says where to calibrate it
from — real market-maker behaviour — and quotes Polymarket BTC-5m market-maker wallets flipping
at roughly a 2 s p10 and an 11 s median. The straddle guard is the other guard the header names;
its mechanics are documented at its call site in the lane.
The session gate reaches this lane too. `EngineParams::session_gate`'s doc is explicit that a
closed market crosses no resting limit and no maker quote, *queued or not*: resting orders, the
tagged maker lane and protective stops are gated on the frozen fill lanes and on their opt-in
`queue_model` tick twins alike.
`EngineCfg::queue_model`'s doc closes with the data requirement: for a real maker backtest, feed
the **trade** tape, and — for true size-ahead seeding — the **book**. Without a book the seed
falls back to the last L1 quote and then to `queue_seed_depth`; [queue
models](/docs/trader/backtesting/queue-models) carries that chain. The natural partner is a
[latency model](/docs/trader/backtesting/latency-models): the queue removes the "always first in
line" optimism, latency removes the "reacts on the same tick" optimism, and
`crates/vike-backtest/src/latency.rs`'s header names the two as partners.
# Queue models (/docs/trader/backtesting/queue-models)
`crates/vike-backtest/src/queue_model.rs` opens with the defect it exists to fix: the frozen
crossing model fills a resting limit the instant price touches it — as if the order were always
**first** in the queue. That over-fills makers. On a real venue an order at price P fills only
after the resting size *ahead* of it at P has been consumed by trades, and this module supplies
that missing estimate — an independent reimplementation of a mechanism common to queue-aware
simulators, with no code taken from any of them.
It is **opt-in and replay-only**: `EngineParams::queue_model = Some(kind)` activates it, `None`
(the default) leaves every existing path byte-identical, and when active it gates only the
`run_ticks` tick/book replay lanes. The bar path never consults it. The lane that consumes these
estimators is [the queue lane](/docs/trader/backtesting/queue-lane); this page is the estimators.
## The composition rule [#the-composition-rule]
The header states the rule the engine wire-in enforces, and every model below lives inside it:
* the **existing fill model** still decides the price condition — limit crossed or touched;
* a **strict price cross** (a trade or quote through the level) fills in full — everyone at the
level, the queue ahead included, was consumed;
* a **touch** (an event exactly at the order's price) is queue-gated: a trade at P first consumes
the estimated front, and only its excess beyond the front fills the order — in full when that
excess covers the whole order, partially otherwise; a quote touch fills only once the front is
already cleared;
* recorded L2 `BookUpdate`s drive `on_depth_change` at each tracked level, so cancellations
ahead of the order shorten — or, for the probabilistic model, statistically shorten — the wait.
That is exactly the depth-consumption channel the crossing model ignored.
## The state and the seam [#the-state-and-the-seam]
`QueueState` is two numbers. `front_qty` is the estimated resting quantity **ahead** of the
order — the simulated order itself is never part of the market book. `cum_trade_qty` accumulates
trade quantity already applied to the front so that a later observed depth *decrease* at the
level is not double-counted, because the venue's book update reflecting those same trades
arrives after the trade prints. Only the probabilistic model consumes it, and it is cleared once
the echo is netted out by a decrease or superseded by an increase — a level that grew past its
pre-trade size has demonstrably already absorbed the print.
`QueueModel` is the estimator seam, and its verbs are pure `f64` state folds with no I/O and no
clock: `on_new_order` — a new order joins the **back** of the level, so everything currently
resting is ahead of it; `on_trade` — a trade at the order's price consumed liquidity from the
front; `on_depth_change` — the level's resting quantity changed from `old_qty` to `new_qty`,
where increases join the back and decreases are attributed front-versus-back per the model after
netting out `cum_trade_qty`; and `is_front_cleared`, true when nothing is left ahead.
## Two estimators [#two-estimators]
**`RiskAdverseQueueModel`** is the conservative bound. Its doc: the front shrinks **only on hard
evidence** — trades at the order's price, or the level itself shrinking below the current front
estimate, since a depth decrease can never leave more ahead of the order than the whole level.
Cancellations behind the order are never credited to the front, so this is the pessimistic,
slowest-fill bound.
**`ProbQueueModel`** splits a net depth decrease `chg` — the observed decrease minus the
`cum_trade_qty` already applied by trades — between the queue ahead and behind by
`prob = f(back) / (f(back) + f(front))`, the probability the cancellation came from behind. The
front estimate becomes `front − (1 − prob)·chg + min(back − prob·chg, 0)` — the last term folds
back-overflow, change the back could not have supplied, into the front — clamped to
`[0, new_level_qty]`. `ProbFunc` is the weight: `Power(n)` is `f(x) = x^n`, where `n = 1` splits
pro-rata by size and a larger `n` biases attribution toward the usually larger back of the
queue; `Log` is `f(x) = ln(1 + x)`, sub-linear, so large backs saturate.
`ProbFunc::f`'s doc is a hazard note. IEEE 754 requires `+ − × ÷` and `sqrt` to be correctly
rounded and requires nothing of `pow` or `log`, so the method spellings call the platform's own
libm, and two operating systems disagree in the last bit. Here that last bit decides a **fill**:
`f` weights the front-versus-back attribution, which moves the estimated queue ahead of a resting
maker, which moves whether a trade's excess reaches the order at all — a backtest could fill on
one box and not on the other. So both arms spell `libm::pow` / `libm::log`. The default
`"prob_power"` (`Power(1.0)`) escapes because that `pow` returns `x` unchanged, but `"prob_log"`
and any `"prob_power:N"` with `N ≠ 1` would otherwise diverge unconditionally — and a site
portable only for one value of its parameter is portable by accident, not by design.
## Selecting one [#selecting-one]
`QueueModelKind` is the `EngineParams` selector — `RiskAdverse`, `ProbPower(f64)`, `ProbLog` —
and `build` constructs the model once, held by the tracker for the whole replay. In a
[run profile](/docs/trader/backtesting/run-profiles) the spellings, case-insensitive, are
`"risk_adverse"`, `"prob_power"` / `"prob_power:N"` (default `N = 1`) and `"prob_log"`; an
unknown string fails the profile at load.
## Seeding a new order [#seeding-a-new-order]
`QueueTracker` is the replay-scoped queue engine — one model plus per-symbol state, built by
`StrategyEngine::new` when `queue_model` is `Some` and absent otherwise. `QueueTracker::seed`'s
doc gives the chain for a new order: the replayed book's level quantity at its price → the
matching side of the last L1 quote → the configured `seed_depth`. The quote match is a
**tick-grid** compare, not bit-exact equality: `same_level` quantizes with round-half-to-even and
compares tick indices, so a strategy price computed arithmetically — `mid − k·tick` accumulating
an ulp — still matches the level it sits on; with no grid at all it falls back to a few-ulp
relative epsilon. `EngineParams::queue_seed_depth`'s default of `0.0` seeds an empty queue, so
an order with no observed size-ahead starts at the front — the old optimistic behaviour, for
that one order.
Book events are folded through two calls: `pre_depth` snapshots the tracked levels' resting
quantity **before** a book event is applied, and `apply_depth` folds the applied event into every
tracked state. A dropped book — a gap marker — leaves states untouched; an unknown old quantity
with a known new one applies the conservative clamp `front = min(front, new)`, because a fresh
anchor after a gap can only bound the front.
`SymQueue`'s doc carries the identity rule both lanes share: states are keyed by real per-order
identity — `WorkingOrder::qid` for untagged orders, the tag plus its current `qid` for maker
quotes — never by a `(side, price)` fingerprint, so a `cancel_all` followed by a re-submit at the
same price re-seeds at the back rather than adopting the cancelled order's advanced front.
# Run profiles (/docs/trader/backtesting/run-profiles)
A backtest is described by one TOML file. `crates/vike-backtest/src/harness/profile.rs`'s
`BacktestProfile` names a venue/symbol/interval data slice, an engine cost and cash
configuration, and a strategy by registry name plus arbitrary TOML params. Unknown top-level keys
are a hard parse error — the struct is `deny_unknown_fields` — so a misspelt section cannot
silently no-op.
```toml
name = "bar-demo" # a free-form label; logs and report titles only
[data]
venue = "binance"
symbols = ["BTCUSDT", "ETHUSDT"]
kind = "bar" # "bar" or "tick"
interval = "1h" # bars only; defaults to "1d"
from = "2026-01-01T00" # epoch-ms as a string, or YYYY-MM-DDTHH UTC
to = "2026-01-02T00"
[engine]
cash = 100000.0
fee_rate = 0.001
slippage = 0.0
[strategy]
name = "buy_hold"
[strategy.params]
size = 1.0
```
`from` and `to` accept a bare epoch-millisecond integer written as a string or a `YYYY-MM-DDTHH`
UTC hour label; `BacktestProfile::range` resolves both to an inclusive range, and `validate`
rejects `from > to`.
## The data slice, in one of two forms [#the-data-slice-in-one-of-two-forms]
`DataCfg` accepts **either** the single-venue pair `venue = "…"` plus `symbols = [ … ]` **or** an
`[[data.series]]` array whose entries each carry their own `venue`, `symbol` and — in tick mode —
lane `kind`. Exactly one form must be present; mixing them is a validation error, not a
precedence rule. The array form is the cross-venue one, and its order is meaningful: the header's
example puts a spot reference series first so that, on an equal-timestamp tie in the tick merge,
the reference sample is delivered before the print it should inform.
Symbols must be unique across the resolved series — the engine routes events to a symbol slot by
name, and two series sharing one symbol would silently land in one slot. A bar-mode profile may
not set a series `kind`: a bar series has no quote/trade/book lanes to filter.
## The engine table [#the-engine-table]
`EngineCfg` carries `cash` (must be `> 0`), a flat `fee_rate`, a flat `slippage`, and opt-in knobs
whose absence is byte-identical to the profile that never mentioned them. Several are
**mode-bound**, and for five of them — `attach_funding`, `[engine.impact]`, `feed_latency`,
`queue_model` and `equity_sample_every` — `BacktestProfile::validate` rejects a profile that sets
one in the wrong mode rather than ignoring it. The other tick-only knobs carry no such check:
`run_backtest`'s bar arm never reads them, so a bar profile that sets one runs as if it had not.
| key | mode | what it does |
| --------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `attach_funding` | bar only — rejected on tick | window-joins the stored `funding` series onto the price bars, one rate per bar whose window contains the event — never forward-filled |
| `[engine.impact]` | bar only — rejected on tick | market-impact slippage on top of the flat `slippage`; the tick lane replays a real book and needs no model |
| `feed_latency` | tick only — rejected on bar | deliver ticks in recorded arrival order while matching stays on venue time |
| `order_latency_ms`, `fill_latency_ms` | tick only — inert on bar | the entry leg and the response leg of order latency, in milliseconds; either `> 0` arms the model |
| `fill_model = "l2book"` | tick only — inert on bar | the depth-capped book-walk fill instead of the default L1 `Tick` model; pair with `slippage = 0.0` |
| `queue_model` | tick only — rejected on bar | `"risk_adverse"`, `"prob_power"` / `"prob_power:N"` or `"prob_log"` — a resting limit fills only once flow has consumed the size ahead of it |
| `queue_seed_depth`, `queue_min_hold_ms` | tick only — inert on bar | the queue lane's seed depth, and a minimum-hold floor on the tagged maker lane's closing fills; both read only when `queue_model` is set |
| `equity_sample_every` | tick only — rejected on bar | thin the equity curve to one sample per `N` ticks (`0` records nothing) |
The string-valued knobs are resolved strictly. `EngineCfg::fill_model_kind` accepts exactly one
spelling, case-insensitively, and anything else is a fail-fast validation error — the earlier
lenience let a typo like `"l2_book"` quietly select the optimistic `Tick` model and undo the
depth-cap it was meant to enable. `queue_model_kind` follows the same rule.
`equity_sample_every` exists for a sweep over a very long tape — the curve costs 16 bytes per
priced tick, so a 100M-tick replay carries 1.6 GB of samples per run whether or not anything reads
them. But every curve-derived figure (max drawdown, Sharpe, the return series) becomes an
approximation, and `0` degenerates them entirely. `final_equity`, `n_trades`, the trade log and
`per_symbol_pnl` never move.
Three sub-tables have their own pages. `[engine.fee]` selects a `vike_model::FeeSchedule` — one
`kind` today, `"probability_scaled"`, the prediction-market curve `qty × rate × p(1−p)` that a flat
rate cannot express; setting it beside a non-zero `fee_rate` is a validation error, because two
cost models would be configured and only one could win ([the cost
model](/docs/trader/backtesting/cost-model)). `[engine.resolution]` builds the binary-outcome settlement
source ([the sim broker](/docs/trader/backtesting/sim-broker)). `[engine.impact]` names the
`"almgren_chriss"` model with its `exec_time` and `window` ([market
impact](/docs/trader/backtesting/market-impact)).
`emulator_release_stops` defaults to **`true`** in the harness — a fired conditional stop is
released as a market order that fills the next event, as the live emulator does — even though the
low-level `EngineParams` default is `false`, so a profile run reproduces live fill timing without
an explicit knob. `snap_to_properties` wires the store's point-in-time instrument grid into the fill
path and is single-venue only: the grid is looked up under one default venue, so `validate` refuses
it beside a cross-venue slice rather than snapping every series to the first series' venue.
With `snap_to_properties` on, bar-mode bars are re-tagged `SYMBOL.VENUE` for the grid lookup, so a
symbol-inferring strategy — `buy_hold` reading `bar.symbol` is the example
`crates/vike-backtest/src/harness/run.rs`'s
`bar_engine_params` gives — needs an explicit `strategy.params.symbol` to route.
## Strategy, risk, sweep, walk-forward [#strategy-risk-sweep-walk-forward]
`[strategy]` is a `name` and a `params` table the strategy's own constructor interprets; which
names resolve is [the harness registry](/docs/trader/backtesting/harness-registry).
`[risk]` carries the **same** schema and the **same** compile-checked converter
(`vike_exec::ProfileRisk::to_risk_limits`) the live run profile uses, so a limit proven here by a
real `SimBroker` denial carries unchanged into paper and live. It is the only way a harness
profile arms the pre-trade gate — `[engine]` has no `leverage` knob. One field is rejected:
`risk.max_orders_per_window` is a wall-clock order-rate throttle, and a replayed month can execute
in under a second.
`[paramscan]` turns the profile into a grid — each key a `strategy.params` field, each value an
array to cross-product over; absent or empty means "not a search". ⚠ **The table is named
`paramscan`, and `sweep` is an ALIAS of it** — a permanent one, because the profile is
`deny_unknown_fields` and every file written before the rename would otherwise stop loading. Both
spellings work; `paramscan` is the field.
`[walkforward]` turns it into a sequence of train/validate windows. Both tables live in the profile
rather than on the wire because a `deny_unknown_fields` profile must parse for the whole TOML to be
shippable verbatim to a remote runner, and both are inert for a plain single run.
⚠ **This paragraph said `[walkforward]` "carries one knob, `n_splits >= 1`" — it carries nine
fields**, and two of them (`purge`, `embargo`) are legal on one of its two window forms only. That
sentence was true when it was written, which is why the table is no longer described by hand here:
[`backtest --profile`](/docs/trader/reference/cli/research/backtest-profile) renders every key, default,
accepted value and refusal from the Rust types that declare them. See also
[sweeps](/docs/trader/backtesting/sweeps) and [walk-forward](/docs/trader/backtesting/walk-forward) for what
the two tables are FOR.
When a profile is loaded from a file, `BacktestProfile::from_path` records the file's directory as
`base_dir`, and relative paths inside the profile — today `[engine.resolution].path` — resolve
against it rather than the caller's working directory, so a profile and its sidecar move together.
# Running a backtest (/docs/trader/backtesting/running)
There is one function that turns a profile into a result, and two front ends that reach it.
`crates/vike-backtest/src/harness/run.rs`'s `run_backtest` loads a `BacktestProfile`'s data slice
from an `Arc` — any backend that implements the trait: the DataFusion store in
production, the ClickHouse-reading store the `poly_ch_backtest` bin feeds through the same
dispatcher, an in-memory store in tests — resolves the strategy by name, and runs it in **bar
mode** (`StrategyEngine`) or **tick mode** (`replay_ticks`) according to `profile.data.kind`.
Both modes load the profile's *resolved* series, so a cross-venue `[[data.series]]` slice and the
single-venue pair take the same code path. The fee schedule and the resolution source are built
once for both. The bar arm's engine parameters and bar loading are factored into
`bar_engine_params` and `load_profile_bars` so the walk-forward runner builds identical parameters
and loads identical bars — the divergent-default bug shape this workspace keeps paying for, closed
by construction. One difference is load-bearing: in bar mode nothing else would wire the
point-in-time instrument grid, so `run_backtest` sets it itself when `snap_to_properties` is on;
in tick mode the flag goes to the replay loader, which wires it from its own closure.
## The `backtest` binary [#the-backtest-binary]
`crates/vike-backtest/src/bin/backtest.rs` is a thin shim over `crates/vike-backtest/src/backtest_cli.rs`'s
`run`. The shim is the composition root and holds the two things a library may not: the one
`std::env::vars()` sweep, and the clock. Its usage:
```
backtest --help | --version
backtest --list
backtest --seed-demo [--store DIR]
backtest --profile run.toml [--store DIR] [--json]
backtest --profile sweep.toml [--rank-by sharpe|return|max_dd|equity|multi]
[--search grid|euler] [--euler-depth N]
[--optimizer tpe [--trials N] [--seed S]]
backtest --fetch VENUE:SYMBOL:INTERVAL (--days N | --from LABEL --to LABEL) [--store DIR]
backtest --fetch-starter [--store DIR]
backtest --export VENUE:SYMBOL:INTERVAL --out FILE [--from LABEL --to LABEL] [--store DIR]
```
`--store` picks the history-store root. Without it the binary resolves `$VIKE_HIST_STORE`, then
`/market_data/hist` for a project `$VIKE_SETTINGS_DIR` declares outright, then
`/market_data/hist` while the checkout that built the binary still exists, then the project
the working directory sits in, and finally a per-user data directory — logging the root it chose
and the rung that chose it. See [the history store](/docs/trader/concepts/hist-store). `--list` prints
every registered strategy name and exits, needing no store and no profile. `--json` prints the
`BacktestReport` — or, for a sweep, the `SweepReport` — as pretty JSON instead of the human table,
and that stream is a machine contract: every diagnostic goes to stderr so stdout stays parseable.
`--help` and `--version` are answered before the log subscriber is initialised, so neither creates
a log directory.
A profile with a `[sweep]` table runs `run_sweep` instead of a single run; `--rank-by` picks the
ranking metric (default `sharpe`, `multi` for the composite objective), `--search euler` replaces
the exhaustive grid with a bounded successive-halving refinement, and `--optimizer tpe` is the
Bayesian search, which takes precedence over `--search` and is reproducible under `--seed`. See
[sweeps](/docs/trader/backtesting/sweeps), [search](/docs/trader/backtesting/search) and
[objectives](/docs/trader/backtesting/objectives).
A clean install has an empty history store, so the shipped example profile reports a run with no
trades, and nothing distinguishes that from a strategy that never fired. `backtest data seed-demo`
writes a **synthetic** tape under the venue `demo` — a closed-form curve, not market data — which
is the slice the shipped `user_data/profiles/backtest.toml` names, so a fresh install can run it
immediately; re-running writes nothing. `backtest data fetch-starter` downloads a published starter
dataset over plain HTTPS, for a box a venue cannot be reached from at all. Both need the
`venue-fetch` feature, which the release binary and the container image have; a build without it
refuses by name rather than silently doing nothing.
⚠ **These were FLAGS — `--seed-demo`, `--fetch`, `--fetch-starter`, `--export`, `--rm-series` — and
they are subcommands now** (`crates/vike-backtest/src/backtest_cli.rs`'s `RETIRED_DATA_FLAGS`
refuses each old spelling by name and prints the new one). ⚠ **`--fetch` did not move, it was
deleted**: `crates/vike-backfill/src/kline_source.rs` records that its venue roster was retired on
2026-09-19 *by deleting the producer*, because that verb reached a venue directly from the compute
plane. Fetching real bars is `vike-cli data hist fetch`, which asks a datahub that dispatches
through the kline registry — one place that knows which venues serve bars, instead of a second
roster inside the backtester.
Before the profile is loaded, the binary installs the user's own Rhai indicators from `user_data`
(honouring `VIKE_USER_DATA_DIR`), because a `rhai` strategy compiled later may call them and
nothing else installs them; a rejected file is reported on stderr and is never fatal. After a
**single** run prints, the binary persists it to `/user_data/runs//` — after
printing, never changing the exit code; a sweep, a euler refinement or a TPE run writes nothing.
See [runs on disk](/docs/trader/backtesting/runs-on-disk).
## `vike-cli backtest`: the remote path [#vike-cli-backtest-the-remote-path]
`crates/vike-cli/src/cmd/backtest.rs` is a different path, not a wrapper. It runs a real backtest
against a `vike-datahub` server from a machine with **no** DataFusion in its build: it reads a
profile's TOML text locally, ships that text verbatim, and prints the compact report the server
returns. The history never crosses the wire, and the server is the only thing that parses the
profile, with `BacktestProfile::from_toml_str`.
```
vike-cli backtest --profile [--preset ] [--script ] [--addr 127.0.0.1:7878] [--json]
vike-cli backtest --list-params --script
```
`--addr` defaults to `127.0.0.1:7878`; the server refuses a non-loopback bind unless it is
explicitly opted into with node keys configured, so a remote one is reached over an SSH tunnel.
The exit code is `0` when a report comes back and `1` on any failure — bad arguments, an
unreadable file, a transport error, or a server-side error — with the message on stderr. `--json`
prints the server's JSON verbatim; without it the JSON is pretty-printed.
Two client-side rewrites happen before the request. `--script` injects a Rhai file's source into
`[strategy.params].src`, so an authored strategy ships self-contained to a server that cannot see
the client's filesystem. `--preset` merges a flat table of knobs into `[strategy.params]`,
last-wins — resolved **here** rather than named in the profile, because a `preset = "fast"` field
would be resolved against the server's `user_data`, not the author's. Two preset shapes are
refused because accepting them would do nothing visible: a `[params]` wrapper (the strategy would
receive one parameter called `params` that no reader looks at) and a `src` key (a script smuggled
past `--script`). `--list-params` is offline — it prints a script's `param(name, default)` knobs
and opens no connection. `vike-cli sweep` and `vike-cli walkforward` take the same compute-to-data
shape (`crates/vike-backtest/src/harness/sweep.rs` and `crates/vike-cli/src/cmd/walkforward.rs`).
`crates/vike-backtest/src/harness/sweep.rs`'s module doc records why: `sweep` and `walkforward` once
re-parsed the profile into client-side DTOs, and the mapping silently dropped `[engine]` costs,
`[risk]`, tick slices and cross-venue slices — so a sweep ran under different costs than the same
profile's `backtest`. Only the `--script` and `--preset` rewrites touch the TOML, each a targeted
edit of `[strategy.params]`.
The [quickstart](/docs/trader/getting-started/quickstart-backtest) walks this path end to end; the flag
tables are on [the CLI reference](/docs/trader/reference/cli/research/backtest) and [the `backtest` binary
page](/docs/trader/reference/binaries/backtest), and the server on
[`vike-datahub`](/docs/trader/reference/binaries/vike-datahub).
# Runs on disk (/docs/trader/backtesting/runs-on-disk)
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 `/user_data/runs/` — the
`vike_model::state_path::RUNS_SUBDIR` location — holding two files:
```
user_data/runs//
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 [#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 [#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-run-id-and-why-the-filesystem-guarantees-it]
The id is `--`: 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.
`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 [#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](/docs/trader/analytics/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.
# Scheduling (/docs/trader/backtesting/scheduling)
A backtest strategy can ask to be called back on a schedule — the first bar of a new month, every twenty bars, 08:00 UTC each day — and `crates/vike-backtest/src/schedule.rs` fires those callbacks at bar boundaries. It ports the original `core/schedule.py` together with `analysis/periods.py`'s `period_key`, with one Rust adaptation: Python registered arbitrary closures, but a stored closure capturing the strategy cannot coexist with the engine's `&mut` borrow of it, so a rule is registered with a string **tag** and the engine fires `Strategy::on_schedule(ctx, tag)` for each due rule. The firing points and cadence are identical.
## Rules [#rules]
`DateRule` is the question "should a scheduled callback fire at this bar?", in three shapes:
* `PeriodStart { period }` fires on the first bar of each new calendar period. `period_key` labels the period containing a timestamp — `"2024-03-15"` daily, `"2024-W11"` weekly (ISO), `"2024-03"` monthly, `"2024-Q1"` quarterly, `"2024"` yearly — and the rule fires when the label changes. `DateRule::month_start`, `week_start`, `quarter_start` and `year_start` are the constructors. The label delegates to `vike_analytics::periods::period_key`: the two used to be hand-maintained twins, one chrono-free and one chrono, a divergence foot-gun, and the backtest is off the latency-gated path so the shared implementation is fine here.
* `EveryNBars { n }` fires when `bar_index % n == 0`; `every_n_bars(n)` asserts `n >= 1`.
* `Time { rule }` wraps a wall-clock `TimeRule` and fires on the first bar at or after each scheduled instant, latching the last instant so it fires once per instant.
`Schedule` is the registry of `(rule, tag)` pairs. `Schedule::on(rule, tag)` registers; `check_due(ts, bar_index)` returns the tags due at this bar, firing each rule at most once per `bar_index`. In `crates/vike-backtest/src/engine.rs`'s step loop the engine fans `on_bar` out per symbol, then calls `check_due` and fires `on_schedule` per tag — and only once the step index has reached `warmup()`, the gate this loop puts in front of `on_bar` and `on_schedule` and the tick lane in front of `on_quote_tick` and `on_trade_tick`. `on_fill` is not behind it (`fire_on_fill` applies no warmup test), and neither is `on_order_book`: book events deliberately do not advance the warmup index and are delivered as soon as a valid book exists.
`schedule` is a public field on `SimBroker`, and the broker's `impl Broker` doc lists it with the other backtest-rich verbs that stay inherent on the concrete broker. Registering a rule therefore takes an `impl Strategy`; `vike_model::strategy`'s module doc names the bar-count schedule as one of the five simulator-only concepts — `every_n_bars` has no wall-clock meaning, and the live schedule takes wall-clock rules as mount configuration, unreachable from a strategy's `on_start`. See [simulator-only strategies](/docs/trader/strategies/simulator-only).
## The shared wall-clock rule [#the-shared-wall-clock-rule]
`TimeRule` lives in `crates/vike-model/src/schedule.rs`, and its home is the point. It is a pure, allocation-free, clock-free `Copy` value type in the domain model, so both consumers reach it down-only: the live runtime fires it off the deadline timer wheel at the drain-loop boundary and keeps the `LiveSchedule` registry that owns a mount's rules, while the backtest drops the same `TimeRule` into `DateRule::Time` and checks it per bar. One rule, one law — `TimeRule::last_instant_at_or_before` is the single crossing primitive both paths call, so a rule computes byte-identical fire instants whether it is driven by bar timestamps or by the injected boundary clock. It is not on the hot path: the live registry is consulted only at the drain-loop boundary, never per market message. [Timers](/docs/trader/concepts/timers) describes the wheel.
A rule fires at `minute_of_day` (0 to 1439) in a fixed-offset timezone on a weekday bitmask (bit 0 Monday through bit 6 Sunday; `0` never fires). The constructors are `daily_at_utc(hour, minute)`, `daily_at(hour, minute, tz_offset_min)`, `on_days(hour, minute, tz_offset_min, weekdays)` — which panics on an out-of-range HH:MM, a programmer error — and `session_offset(anchor_minute_of_day, offset_min, tz_offset_min, weekdays)` for a market-open or market-close offset, wrapping into the day: "5 minutes before the 16:00 close" is `session_offset(960, -5, tz, TimeRule::WEEKDAYS)`, which is 15:55. `TimeRule::ALL_DAYS` and `TimeRule::WEEKDAYS` are the two shipped masks.
`tz_offset_min` is a fixed offset from UTC in minutes — UTC is `0`, UTC+2 is `120`, UTC−5 is `−300`. That covers the 00:00 UTC and 08:00 UTC expiry cases and any fixed-offset market; an IANA, DST-aware variant is named as a future extension because it needs a calendar dependency this pure core avoids.
## The latch [#the-latch]
`TimeRule::crossed(now_ms, last)` advances a per-rule latch and reports whether a new instant was crossed. The first call establishes the latch at the most recent past instant **without firing**, so a schedule mounted — or restored after a restart — mid-period fires at the next instant, never a spurious catch-up of the one that already passed. Thereafter it fires only when the most recent instant has advanced, coalescing any instants skipped across an idle gap into a single fire of the latest. `DateRule::Time` calls that same `crossed`, from the same definition, which is what makes a rule fire at byte-identical points on both stacks.
# Search (/docs/trader/backtesting/search)
A cartesian [sweep](/docs/trader/backtesting/sweeps) evaluates every point of every axis, so resolution costs exponentially. Two search lanes buy resolution cheaper, both over the same `[sweep]` table, both returning the same ranked `SweepReport`, both scoring through the same [objective](/docs/trader/backtesting/objectives) seam. Neither reimplements scoring or backtesting.
## Successive halving [#successive-halving]
`crates/vike-backtest/src/search.rs` is the pure, store-free core of Euler search. Evaluate the coarse grid once; then, up to `max_depth` times, halve every axis's step and evaluate the `{best − step, best, best + step}` neighbourhood of the incumbent — at most `3^d − 1` new points per depth, clamped to each axis's box and deduplicated against everything already seen. After `D` halvings the effective step is `step / 2^D`, the resolution of a grid with `2^D·(n_i − 1) + 1` values per axis. The module's own arithmetic: a two-axis 5×5 grid at depth 3 costs at most 25 + 24 = 49 evaluations against 33×33 = 1,089 for the equal-resolution grid.
A halving that fails to improve the incumbent is not a stop condition — it is the signal to shrink again, since only a smaller step can reach the points between the incumbent and the ones just rejected. The search runs its full depth budget and stops early only when a depth produces no unseen candidate at all. `SearchTrace::depth_reached` therefore reports the resolution actually attained, and `EulerConfig` caps depth at `MAX_DEPTH_CAP` of 16 (already 65,536× the coarse resolution), with `DEFAULT_MAX_DEPTH` of 3; depth 0 evaluates exactly the grid. A `SearchAxis` refines at its own smallest existing gap, an integral axis rounds every candidate and stops once its step falls below 1, and a single-valued axis stays pinned. Scoring follows the objective convention: higher is better, `NaN` is unrankable and can never displace a finite incumbent — it is only ever the incumbent while nothing finite has been evaluated.
Successive halving can only descend into the basin its coarse grid already found; a grid too sparse to see a narrow optimum refines the wrong peak, pinned by `refinement_stays_in_the_basin_the_coarse_grid_chose`. Keep the coarse grid wide, the depth bounded, and treat this as a refinement of a grid, not a replacement for one.
`crates/vike-backtest/src/harness/euler.rs` is the store-backed wrapper, reached with `--search euler`; the default `--search grid` is untouched. `run_sweep_euler` returns the grid path's `SweepReport` — only evaluated points appear as rows, which is the saving, and a failed backtest scores `NaN`, which can never displace a finite incumbent as the refinement centre — plus an `EulerBudget` whose `equivalent_grid` is computed at the depth **actually** reached, never the requested `max_depth`, so an early-stopped run cannot advertise a saving it did not buy. It prints as `euler: N evaluations at depth d/D (grid at the depth reached would be G)`; `equivalent_grid_evaluations` states its precondition, a uniformly spaced axis, and that a non-uniform axis overstates what the search covered. Every `[sweep]` axis must be numeric and type-homogeneous: a mixed `[1, 2.5]` axis would silently change the TOML type the strategy receives versus the grid path, so a non-numeric or mixed axis is a `HarnessError::Validation` pointing at `--search grid`, and `MAX_AXES` of 8 is where the `3^d` neighbourhood stops being cheaper than the grid. Points within one depth are independent backtests and run on the bounded sweep pool; depths stay sequential, and the trace, rows, budget and ranking are identical to the one-at-a-time path.
## TPE Bayesian optimization [#tpe-bayesian-optimization]
`crates/vike-backtest/src/harness/tpe.rs` is the ask/tell alternative: it converges on good `strategy.params` by modelling which regions of the space produce good scores and proposing there. It is a clean-room implementation of the mathematics of Bergstra, Bardenet, Bengio and Kégl, *Algorithms for Hyper-Parameter Optimization* (NeurIPS 2011), §4, restated for the maximise convention. The history is split at the `gamma` quantile of score into good and bad; per parameter, independently, two Parzen densities `l(x)` and `g(x)` are built; expected improvement is monotone in `l(x)/g(x)`, so a continuous parameter draws `n_candidates` samples from `l` and proposes the argmax of the ratio, while a discrete parameter samples its next category from `l` directly, because an argmax over a small category set would lock onto the incumbent. A continuous parameter uses an adaptive Parzen estimator — one Gaussian per observation plus a broad prior at the domain midpoint, each bandwidth the maximum distance to its sorted neighbours, clipped — and a discrete one a prior-smoothed frequency histogram. The first `n_startup` asks are random, to seed the model.
`TpeConfig` carries `n_trials` (`DEFAULT_TRIALS` 64), `seed`, `gamma` (0.25), `n_startup` (10) and `n_candidates` (24). A profile's `[sweep]` axes become continuous ranges bounded by each axis's minimum and maximum, integral axes staying whole — TPE explores between the grid points, the same philosophy as successive halving — through the same `numeric_sweep_axes` reader and the same rejection of non-numeric or mixed axes. `run_tpe` asks, runs the proposal through `run_backtest`, scores it and tells the result back, for `n_trials` trials; the loop is inherently sequential because each proposal reads the whole history, so there is no rayon fan-out and determinism rests entirely on the seed.
Every stochastic step draws from a seeded `SplitMix64` the optimizer owns, chosen over a library generator because its ten lines are fixed forever. The module records a correction dated 2026-08-28: the contract claimed byte-for-byte reproducibility and did not hold across platforms. The integer stream was never the leak; the two float transforms on top of it were — a Box–Muller `ln` and `cos`, and the density's `exp` — because IEEE 754 requires nothing of those functions, so an `f64` method call reaches the platform's libm and two boxes disagree in the last bit. One perturbed draw moves a candidate, which moves the argmax, which moves every later proposal: the sequences fork. All three now call the `libm` crate, one pure-Rust implementation compiled into the binary; `.sqrt()` stays a method because IEEE 754 does require it correctly rounded. `same_seed_same_proposals` pins within-box determinism only.
## From the command line [#from-the-command-line]
`backtest --profile sweep.toml --search euler [--euler-depth N]` (default 3, capped at 16) writes a one-line budget summary to stderr; `--optimizer tpe [--trials N] [--seed S]` (defaults 64 and 0) takes precedence over `--search`. Both reuse `--rank-by` as the objective and stamp a `score` on every row whose backtest succeeded; a failed row carries none. The [TPE parameter search](/docs/trader/tutorials/tpe-parameter-search) tutorial runs both over one profile.
# The sim broker (/docs/trader/backtesting/sim-broker)
`crates/vike-backtest/src/engine/sim_broker.rs` holds `SimBroker`: the engine state plus the
strategy-facing verbs and reads — the `ctx` every `Strategy` handler receives. It was split out of
`engine.rs` with behaviour byte-identical, and it carries the two types a run is configured with:
`EngineParams`, the cost and knob surface, and `SymbolState`, the mutable state kept once per
symbol.
## Per-symbol state [#per-symbol-state]
Each symbol owns a `SymbolState`: the `Position`, the untagged `pending` orders, realized PnL, the
armed protective `stop`, entry fee and timestamp, the last seen `price`, the running high and low
since entry (for MAE/MFE), the higher-timeframe aggregates, the granular sub-bars, and the
`tagged` map — resting tagged limit orders for the maker lane, keyed by the strategy's stable tag.
That map is an `IndexMap` so fill order is deterministic insertion order, and it is kept separate
from `pending` so the parity-gated untagged paths are untouched: it is empty for every non-maker
strategy. A filled or cancelled tag is removed, which reproduces the live tag registry's
observable semantics.
## The parameters [#the-parameters]
`EngineParams::default()` is a fee-free, slippage-free run with `10_000.0` cash, multiplier `1.0`,
the `Bar` fill model and every opt-in knob off; each knob's absence is byte-identical to the
engine before it existed. The ones you meet first:
* `fee_rate`, `maker_fee`, `taker_fee`, `fee_schedule`. A schedule overrides the flat chain by its
`maker_taker_rates()` — except the probability-scaled prediction-market curve, which has no flat
equivalent and is routed to the exact per-fill `FeeSchedule::commission` rather than silently
charging nothing.
* `fill_model`: `Bar` (default), `Tick` (L1 spread-crossing) or `L2Book` (a taker consumes the
replayed book for its own size; degrades to `Tick` where no book exists — pair it with
`slippage: 0.0`, the walk is the slippage). See [fill models](/docs/trader/backtesting/fill-models).
* `leverage`, `risk_limits`, `clamp_to_leverage` — the pre-trade gate, below.
* `maint_margin`, `liq_buffer` (default `0.10`), `venue_style_liquidation` — the margin call.
* `cash_gate`, `active_mask`, the three open-position caps, `volume_limit` and `sizer`.
* `properties`: an optional point-in-time grid lookup `(venue, symbol, ts) → grid`, a plain
closure the harness builds over the store.
* The tick-lane knobs (`queue_model`, `latency_model`, `equity_sampling`), the settlement lanes
(`resolution`, `option_specs`, `settlement_period_ms`), `max_price_staleness_ms`, `session_gate`
and `emulator_release_stops`.
## Every verb but one crosses the same gate [#every-verb-but-one-crosses-the-same-gate]
The intake verbs are `submit`, `submit_close`, `submit_limit`, `submit_stop`, `submit_trailing`,
`submit_market_close`, `submit_limit_close`, `cancel_all`, and `strategy_order_target_percent`,
which sizes through `vike_model::units_from_percent` — the same converter the live target verb
uses, so a multiplier-bearing instrument sizes identically live and in backtest.
Every opening verb runs the sizer first (`size_entry`, opening or increasing entries only, passed
through when `raw`), then `gate_order`. The default gate **is the literal live gate**: the order
crosses `vike_exec::RiskGate::check` over a `RiskContext` built from the engine's own state,
mirroring the live construction field for field, and a refusal is whole — zero fill — recorded in
`dropped` under the gate's own reason string, so a backtest denial is the live denial.
`build_risk_gate` mounts it once: explicit `risk_limits` verbatim; else `leverage: Some(L)` mapped
to an initial-margin requirement of `1/L` (the doc carries the equivalence to the retired
leverage-room formula and its limits); else no gate and a submit path byte-identical to the
pre-gate engine. The mounted gate always has `max_orders_per_window` disarmed, because sim time
is not wall time. `clamp_to_leverage = true` is the escape hatch: `cap_to_leverage` truncates the
order to the remaining room and executes it silently, and the gate is never consulted.
`gate_order` was `gate_market_order` and ran for the two market verbs alone; the limit, stop,
trailing and tagged maker submits faced no pre-trade gate, so a backtest of a limit strategy ran
with the operator's `[risk]` budget effectively off while live gated all of them — the backtest was
the permissive side. Every order-submitting verb but `submit_close` is gated now. A covered
reducing order bypasses the floors, the collar and buying power — on a bankrupt account
included — but still faces the per-order notional and projected-exposure caps. The live half is
[risk and arming](/docs/trader/concepts/risk-and-arming).
## One fill-application path [#one-fill-application-path]
Every fill dispatcher ends in `apply_fill`. It prices the adverse move through `slippage_for` —
the flat `slippage`, plus the size-dependent impact addend for taker fills only, because an order
that rested and got hit was supplying liquidity and charging it impact would fill a resting limit
through its own price — then snaps to the point-in-time grid when one is configured, price to the
tick and size to the step through `vike_model::round_to`. An **opening** fill below `min_qty` or
`min_notional` is rejected under that reason; a **closing** fill always executes, falling back to
the raw size if step-rounding zeroed a remainder, so a position is never stranded below the
minimum. `is_maker` is decided by order kind — a `Limit` books as a maker fill even when
marketable — and the fee is the flat fold or the exact schedule commission. The post-cost fold is
shared with `settle_at_payout`, the fee-free, slippage-free, grid-free settlement a resolved
market or expired option closes through.
The opening/closing split is direction-only, while the live gate's floor bypass requires
coverage, so a below-minimum **reversal** executes whole here and is denied live;
`below_min_reversals` counts it. The field's doc argues the divergence is unreachable above dust
at real venue floors — reversing a position worth under about five dollars — and that fixing it
would change fill decisions without converging the engines, so the counter stays as a claim a run
can contradict.
## Reads, and the diagnostics [#reads-and-the-diagnostics]
The reads a strategy steers on: `position_of`, `price_of`, `equity_now` (cash plus signed
notional, folded in symbols order with the compensated sum), `drawdown_now`, `pending_of`,
`protective_stop_of`, `in_flight_of`, `is_resolved`, `price_is_stale`, `symbol_is_open`, and the
higher-timeframe `bars_for` / `forming_for`. The predicates are total — an unknown symbol answers
the permissive default (`false` for `is_resolved` and `price_is_stale`, `true` for `symbol_is_open`)
rather than panicking inside a callback — while the write verbs fail loudly on one.
With a latency model armed, a submitted order still in flight is in neither `pending_of` nor the
position. The idiom `if pending_of(s).is_empty() { submit(..) }` re-fires on every tick of the
entry window; write it as `pending_of(s).is_empty() && in_flight_of(s) == 0`. The tagged maker
lane is immune — an insert under an existing tag replaces it. See [latency
models](/docs/trader/backtesting/latency-models).
What a run leaves on the broker: `dropped` — `(symbol, reason, size, weight)` for every refused
fill, with reasons including the order kind for cash-gate drops, `"volume_cap"`,
`"latency_reject"`, `"min_qty"` / `"min_notional"` and the live gate's own strings;
`stale_deferrals` and `session_deferrals`, so a run with few trades can say "the venue was shut"
rather than "the strategy never traded"; `slippage_saturations`, fills priced on the adverse-move
floor; `intrabar_both_hit`; `settlements` and `variation_settlements`; and the trade list. The
open-position caps count positions, never resting or in-flight orders, at fill time. Under
`settlement_period_ms` each period's mark-to-market is booked into realized and the cost basis
reset, with equity invariant by construction.
# Staleness (/docs/trader/backtesting/staleness)
A repeated last price is not a tradeable price. `crates/vike-fills/src/staleness.rs` opens with
the case: on sparse feeds — Polymarket especially, where an outcome token can go minutes without
a trade — a resampled series emits flat, forward-filled bars, and a market order that fills
against one manufactures edge that never existed. The discipline this module supplies makes such
an order **wait** for a fresh print.
It is enabled only by `EngineParams::max_price_staleness_ms`. `None`, the default, means the
module is never consulted and every fill lane is byte-identical to the pre-feature engine; the
header lists the parity and golden suites that all run with `None`. A
[run profile](/docs/trader/backtesting/run-profiles)'s `[engine]` table carries no key for it — the
knob is set on `EngineParams` by a caller building the engine directly.
## The three rules [#the-three-rules]
The header states them once, and each is one function.
1. **What counts as a fresh print** — `is_fresh_print`. An event carrying genuine current price
evidence: `volume > 0.0` (a real transaction happened in this event or period) **or** a live
two-sided quote (`bid` and `ask` both present — resting liquidity you can actually cross). A
zero-volume, quote-less bar is precisely the fill-forward shape, and is the thing this
discipline exists to refuse. A one-sided quote is not crossable and is not a print.
2. **Which orders defer** — `defers`. `Market` and `MarketClose` **only**. Those resolve at
"whatever the current price is", so a stale price silently becomes their fill price. `Limit`,
`LimitClose`, `Stop`, `Trailing` and the protective-stop lane are price-conditional: a repeated
stale price cannot spuriously satisfy a condition it did not already satisfy, and gating a
protective stop would strand live risk. They pass through untouched.
3. **When a price is stale** — `is_stale`. The age of the symbol's last fresh print, measured
against the event clock, **strictly** exceeds the bound. Age `0` — this very event is a
print — therefore always fills. A symbol that has not printed at all yet is stale by
definition: there is no evidence of a tradeable price, so its market orders keep waiting.
Because the comparison is strict, `Some(0)` is the tightest useful setting — fill only on an
event that is itself a fresh print. A negative bound is clamped to `0` rather than taken
literally: read literally, `age > −1` would make even age `0` stale and nothing could ever fill,
a silent total freeze from a plausible typo. A negative age — an out-of-order event stamped
before the last print — is never stale.
## What a deferral is [#what-a-deferral-is]
A deferred order is not cancelled and not rejected, and it is never lost. It stays in `pending`
exactly as it was and is retried on the very next event for that symbol, so it fills on the next
fresh print, at that print's price. The only way a deferred order stops resting is the way any
resting order does: the strategy cancels it, or an opt-in resolution or expiry lane retires it.
`SimBroker::stale_deferrals` counts how often a deferral happened, and it is mirrored onto
`BacktestResult::stale_deferrals` by both `run` and `run_ticks`, so callers that drop the
engine — the harness and the `backtest` bin — can read it too. The
[result](/docs/trader/analytics/result) carries it for one reason: "zero trades because the tape never
printed" must stay distinguishable from "zero trades because the strategy never traded". The
[zero-trade analyzer](/docs/trader/analytics/sizing-and-zero-trade) turns a non-zero count into a
named cause, `stale-price`, whose detail says the tape never printed fresh data within the bound
and may be forward-filled or zero-volume across the window.
A strategy can also ask before it sends. `SimBroker::price_is_stale(symbol)` is the read-only
surface — a maker can check it before sending a taker order it knows would be deferred. It
answers `false` when the discipline is off and on an unknown symbol, and on the bar path it
measures against **that symbol's own current bar timestamp** rather than the engine clock, so
that on a ragged multi-symbol tape the predicate agrees with the engine's actual deferral
decision.
## The clock is event time [#the-clock-is-event-time]
Every age is `event_ts − last_print_ts`, both drawn from the tape. Nothing reads a system clock,
so a replay of the same tape yields the same deferrals on any machine at any speed — the
determinism the golden and parity suites depend on.
## Which tier it actually bites [#which-tier-it-actually-bites]
Forward-filled and no-trade bars are real on `StrategyEngine::run`, including the cash-gated
lane: a resampled sparse series emits flat zero-volume bars, and without this knob a market order
fills against them. Freshness is recorded once per symbol per step from that step's coarse bar,
immediately before the fill phase, so a bar that is itself a print has age `0` and fills on the
spot.
On the granular sub-bar lane each sub-bar is a first-class print: it is recorded before its own
fill pass and the age is measured at the sub-bar's timestamp, because recording only the coarse
bar while measuring at sub-bar time would make every later sub-bar of a fully fresh step
spuriously stale. A fully printing granular step never defers; a quiet stretch within a step
does, which is the point of the finer tier.
The tick tier is inert **by construction**, and the header says that is correct: each fill event
there is the symbol's own just-arrived tick, recorded as a print immediately before the fill
phase, so the age is always `0`. Freshness there is decided from the tick kind — a `Tick::Trade`
counts as a print regardless of its reported size, since some venues emit zero-size index prints
that would project to zero volume with no quote. The tick path already waits for fresh data
structurally, because an order rests until the symbol's next tick arrives; the gate is still
wired into it so the policy has one definition rather than two. Book events never reach the
price/fill path at all, so there is nothing to gate.
The knob composes with the other bar-lane knobs. `crates/vike-backtest/tests/fills/knob_composition.rs`
arms [impact](/docs/trader/backtesting/market-impact), staleness and variation-margin settlement on
one run and proves each stays individually observable while the others are on — staleness there
pushes a fill one bar out, onto a bar whose open is a different price.
# Sweeps (/docs/trader/backtesting/sweeps)
A sweep is a [run profile](/docs/trader/backtesting/run-profiles) with a `[sweep]` table. Each key names a `strategy.params` field and each value is a TOML array of values to cross-product over; `crates/vike-backtest/src/harness/sweep.rs`'s `expand_sweep` turns the profile into the cartesian product of single-run profiles, each with its params overridden per point and its own `sweep` field cleared, so every expanded point is itself a plain runnable profile. A profile with no `[sweep]` table, or an empty one, expands to exactly one point with no overrides — the non-sweep path is unchanged.
Two rules apply at expansion, each a `HarnessError::Validation`: every entry must be a non-empty array (`sweep_axis_array`), and the product is returned in a deterministic order — axes sorted by key, values in array order, the last axis varying fastest. The grid keeps each raw `toml::Value`, so a string or boolean axis is fine here; the [search](/docs/trader/backtesting/search) lanes narrow the same table to numeric axes.
## Running and ranking [#running-and-ranking]
`run_sweep` runs every `SweepPoint` through `run_backtest` — the same dispatcher a single run uses — and collects a `BacktestReport`, or the error string, per point. A per-point failure does not fail the sweep; it is recorded as that row's `error` so the report still shows every point and which ones failed. Successful rows are ranked by a `RankMetric` — `sharpe` (the default), `return`, `max_dd` or `equity` — with failed rows last. `RankMetric::cmp_reports` orders a `NaN` sort key last rather than first, so a Sharpe over a zero-variance curve cannot win the sweep and feed downstream selection. `run_sweep_with` ranks the same rows by any [objective](/docs/trader/backtesting/objectives) instead, stamping each row's `score`.
The result is a `SweepReport { rows, rank_by }`, each `SweepRow` carrying its `overrides`, its `report` or `error` — never both — and an optional `score`. A non-finite score serialises as `"score": null`, which the module pins as the documented meaning of "unrankable" independent of the serialiser. Sharpe is annualised through `harness::report::periods_per_year` for the single run and every sweep row alike, so both rank on the same scale.
## Parallel by default, byte-identical either way [#parallel-by-default-byte-identical-either-way]
Every expanded point is an independent single run over one shared `Arc`, so the points run on a rayon pool. Determinism does not rest on scheduling: `par_iter().map().collect::>()` is order-preserving, so rows come back in `expand_sweep` order regardless of which point finished first, and the ranking sort is `slice::sort_by`, which is stable, over that same order — the identical bytes the one-at-a-time loop produced, pinned by `parallel_and_sequential_sweeps_are_byte_identical`. `crates/vike-backtest/src/harness/sweep.rs`'s `sort_scored_rows` names the stable sort as part of what keeps the parallel and sequential execution paths byte-identical, and its `SweepExec` doc calls the choice purely an execution-strategy knob: swap either property for an unordered collect or an unstable sort and byte-identical output dies.
The shared `Arc` shares the store handle, not the loaded data: each point calls `run_backtest`, which re-scans and materialises its own `Vec` or `Vec`. Peak RSS and store I/O are `N_threads × slice`, not `1 × slice`, and these sweeps run on boxes that also host live trading. The points therefore never run on rayon's global pool: `install_bounded` builds a pool of `sweep_threads` workers — `VIKE_SWEEP_THREADS` when it parses to a positive integer, else `min(4, available_parallelism())`, with `DEFAULT_SWEEP_THREADS` of 4 kept small on purpose and never zero, because rayon reads zero as one worker per core. Size the variable against the slice, not the core count.
A pool that fails to build — the OS refusing a thread spawn — is logged and the work runs on the ambient pool instead; the results are identical, only the bound is lost. `VIKE_SWEEP_SEQUENTIAL=1` (the exact string `"1"`) pins the whole process to the one-at-a-time loop, and every entry point has an `_exec` twin taking `SweepExec` explicitly, which is what the determinism tests use so they never touch the process environment. `map_bounded` is the public front door for another crate's sweep — `crates/vike-studio-core/src/run.rs`'s `run_sweep_slice` reaches the same bounded pool through it without taking a rayon dependency of its own.
On a long tick tape the equity curve is the other memory multiplier. `EngineCfg::equity_sample_every` exists as the sweep knob for it: the curve costs 16 bytes per priced tick, so a 100M-tick replay carries 1.6 GB of samples per run, paid by every point whether or not anything reads them — at the price of making every curve-derived figure an approximation. It is tick-mode only and rejected on a bar profile.
## Shared optimizer glue [#shared-optimizer-glue]
This module also owns the per-point plumbing the search lanes fold through instead of re-rolling it: `numeric_sweep_axes` (the one `[sweep]` reader, adding the numeric and type-homogeneity narrowing), `profile_with_overrides` (base plus overrides into the single-run profile every point becomes), `eval_scored_point` (point to scored row plus steering score) and `sort_scored_rows` (the best-first ordering over `cmp_scores_desc`). One implementation means the grid, successive halving and TPE cannot drift apart on validation, evaluation or ranking, regression-gated by `euler_and_tpe_rank_rows_exactly_like_run_sweep_with`.
## From the command line [#from-the-command-line]
`backtest --profile sweep.toml [--rank-by sharpe|return|max_dd|equity|multi] [--search grid|euler] [--optimizer tpe [--trials N] [--seed S]]` — the standalone `backtest` binary detects the `[sweep]` table itself; `--rank-by` is ignored, not an error, on a non-sweep profile. A sweep prints a ranked table (or `SweepReport` JSON under `--json`) and, unlike a single run, writes nothing under `user_data/runs/` — it is a different document and a different run kind, deliberately not smuggled into the manifest yet. See [runs on disk](/docs/trader/backtesting/runs-on-disk), the `vike-cli` [sweep verb](/docs/trader/reference/cli/sweep), [running a sweep on the data service](/docs/trader/guides/sweep-on-the-service), and the [sweep and walk-forward](/docs/trader/tutorials/sweep-and-walk-forward) tutorial.
# Tick replay (/docs/trader/backtesting/tick-replay)
Tick replay is the offline twin of the live tick recorder. `crates/vike-backtest/src/hist_replay.rs` turns the quote, trade and book rows a `HistStore` recorded into the input of `StrategyEngine::run_ticks` and hands back the same `BacktestResult` a bar run produces. The whole module sits behind the `hist-replay` Cargo feature, compiles against the `HistStore` trait alone and names no DataFusion type; only `replay_ticks_streaming`, gated a step further behind `datafusion-store`, opens the concrete `DataFusionHist`.
## From store rows to one stream [#from-store-rows-to-one-stream]
The pure core is `merge_ticks`: a linear three-way step over one symbol's already ts-ascending quotes, trades and book events into a single ts-ordered `Vec`. At an equal timestamp the tie-break is **Book, then Quote, then Trade** — book first because a live derived quote follows the book application that produced it, quote before trade because that was the original documented policy. `crates/vike-backtest/src/hist_replay.rs`'s `merge_ticks` states the consequence: reordering that tie-break breaks live/replay parity.
`replay_ticks` is the bounded reference loader. For each symbol it scans `scan_quotes`, `scan_trades` and `scan_book_updates` over the configured range, merges them, optionally seeds closed-bar context in process through `vike_model::consolidate_quotes` (or `consolidate_trades` when no quotes exist — book events never seed bars), and calls the existing `run_ticks` with no engine changes. A symbol with zero rows of all three kinds is skipped with a `tracing::warn!` rather than an error, so an all-sparse window degrades to an empty-but-valid result. `replay_ticks_streaming` obtains quotes and trades through `DataFusionHist`'s row-group streams instead; in its current scope each stream is still collected into a `Vec` before the merge, so the loader is not O(batch) end to end — its point is API shape plus bit-identical output against `replay_ticks`, pinned by `streaming_equals_bounded_bit_eq`. Both entry points share `replay_ticks_core` and differ only in how a symbol's rows are obtained.
Inside the engine, `run_ticks` k-way merges the per-symbol streams by timestamp (ties by stream order) and routes each tick by its own payload symbol. `on_bar` never fires on this path; a strategy sees `on_quote_tick`, `on_trade_tick` and `on_order_book`.
## The book integrity rule [#the-book-integrity-rule]
A recorded book chain is feed-local contiguous, and the replay treats it exactly as the live consumer did. `crates/vike-backtest/src/engine.rs`'s `apply_book_event` anchors a per-symbol book on a `Snapshot`, and applies a `Delta` only when the shared `L2Book::delta_decision` law answers Apply under `SeqPolicy::Strict` — `seq == last_seq + 1`, the same decision the live depth fold consults. Anything else — a gap, an orphan, a recorded gap-start or stale marker — drops the book until the next snapshot re-anchors it. `on_order_book` is delivered only on a state-changing apply. The books live on the broker, so the `L2Book` fill tier and a strategy's `quote_vwap` and `depth_within_price` reads answer from the same state the loop maintains.
## What the config controls [#what-the-config-controls]
`TickReplayConfig` carries the run's default `venue`, its `symbols`, the `range`, and `params` — the caller's cash, fees and sizer, with `fill_model` and `default_venue` overwritten by the loader, since a tick replay only makes sense under the L1 spread-crossing tier tagged to one venue (an explicit `L2Book` choice is respected — see [fill models](/docs/trader/backtesting/fill-models)). Three opt-ins default off and are byte-identical when off:
* `seed_bar_interval_ms` seeds closed bars. It must be positive (`ReplayError::InvalidBarInterval` otherwise), and with several symbols the per-symbol bar counts must agree, or the loader returns `ReplayError::MisalignedSeededBars` instead of driving `StrategyEngine::new`'s length assertion into a panic — bar-seeded replay is single-symbol in this phase.
* `snap_to_properties` wires `HistStore::properties_as_of` into `EngineParams.properties`, so a fill rounds price to tick and size to step and an opening fill is gated against the grid in effect at that tick's timestamp. It is single-venue, because the grid is looked up under one `default_venue`.
* `series` replaces the `venue × symbols` expansion with an explicit list of `SeriesRef`s, each naming its own venue and a `SeriesKind` lane filter (`tick`, `quote`, `trade`, `book`), so a Polymarket outcome token's trade tape can ride beside a spot quote series. Symbols must stay unique across the list, and `BacktestProfile::validate` refuses `snap_to_properties` alongside a series list naming more than one venue, for the single-venue reason above.
A profile reaches all of this with `data.kind = "tick"`: `crates/vike-backtest/src/harness/run.rs`'s `run_backtest` builds the config from `[engine]`, and `[[data.series]]` maps 1:1 onto `SeriesRef`. Series order matters — the k-way merge breaks an equal-timestamp tie by stream order, so a reference series listed first is delivered before the print it should inform. Three of the tick-only knobs (`feed_latency`, `queue_model`, `equity_sample_every`) are rejected at validation on a bar profile rather than ignored; `order_latency_ms` and `fill_latency_ms` are read only by `run_backtest`'s tick arm, so a bar profile that sets either runs as if it had not.
## Feed-latency replay [#feed-latency-replay]
Recorded ticks carry two clocks: the venue's `ts` and the machine's `local_ts`. The default replay orders by the venue clock, so the strategy sees each event the instant the venue stamped it — earlier than any real consumer could. On a proxied feed that systematically flatters the result: the strategy reacts to prints it could not yet have had.
`feed_latency: true` orders each symbol's merged stream by `tick_arrival_ts` instead: `max(local_ts, ts)`, with an unstamped `local_ts <= 0` falling back to the venue stamp and clock skew clamped up, so the modelled latency is never negative. Nothing is retagged — every tick keeps its venue `ts`, which remains the only clock `run_ticks` matches resting orders, advances `core.now` and stamps the equity curve with. Only delivery order moves, through a stable sort, so an unstamped tape replays byte-identically in either mode. Two limits are stated in the module rather than hidden: the re-order is per symbol, so on a multi-symbol replay the cross-symbol interleaving stays venue-ordered; and a genuinely lagging tape makes the per-tick clock non-monotonic, which is the honest shape of "the consumer saw it later" — combining this mode with the opt-in latency, resolution or settlement arms is out of scope.
See [latency models](/docs/trader/backtesting/latency-models) for order latency on this path, [queue models](/docs/trader/backtesting/queue-models) for resting maker fills, and the [record-and-replay](/docs/trader/tutorials/record-and-replay) tutorial for a run end to end.
# Timeframes (/docs/trader/backtesting/timeframes)
A backtest runs on one base stream per symbol. Every coarser timeframe a strategy reads is synthesised from it, in process, by `crates/vike-backtest/src/timeframe.rs` — an exact port of the original `core/timeframe.py` — and served through a buffer that is look-ahead-safe by construction: a coarse bar becomes visible only once its window has fully elapsed, and the engine enforces that visibility.
## The interval vocabulary [#the-interval-vocabulary]
`parse_timeframe` accepts a string like `"30s"`, `"1m"`, `"4h"` or `"1d"` and returns milliseconds. It delegates to the single interval vocabulary in `vike_model::time::interval_ms`: an integer count followed by exactly one unit character — `s`, `m`, `h` or `d` — with anything malformed rejected. That delegation is why seconds work: the module's own test pins `"30s"` to 30,000 ms, `"1m"` to 60,000 and `"4h"` to 14,400,000, and records that a `"30s"` series which loaded fine from the history store used to be an invalid backtest timeframe before the vocabulary was unified.
The interval also sets the report's annualisation. `crates/vike-backtest/src/harness/report.rs`'s `periods_per_year` derives the number of return observations a year produces from the interval — `252 · (86,400,000 / interval_ms)`, so `1h` gives 6,048 and `1m` 362,880 — with the `1d` anchor preserved bit for bit. It used to return 252 for every interval, which understated every intraday Sharpe by `sqrt(1440) ≈ 37.9x`. See [the report](/docs/trader/analytics/report).
## Resampling [#resampling]
`resample(bars, target_ms)` aggregates fine bars into coarse ones aligned to epoch windows of `target_ms`. A bar's window start is `ts − ts.rem_euclid(target_ms)` (Python's floor-mod), and one coarse bar is emitted per window in chronological order: open from the first fine bar, high and low folded across the window, close from the last, volume summed. The final window may be partial. `funding`, `bid`, `ask` and `symbol` are `None` on a resampled bar.
## The buffer [#the-buffer]
`crates/vike-backtest/src/bar_buffer.rs`'s `BarSeriesBuffer` is the shared multi-timeframe buffer, ported from `core/bar_buffer.py` with `bisect` becoming `partition_point`. Where the Python shared its base list by reference between engine and buffer, here the buffer owns the base-bar `Vec` and the engine reads through it — one owner, same observable behaviour. `BarSeriesBuffer::new(bars, timeframes)` resamples every registered timeframe up front; `add_live_bar` appends a base bar and refreshes each aggregate, the forward mode.
Two reads answer the strategy, both keyed on `now`:
* `bars_for(tf, now)` returns the completed higher-timeframe bars visible at `now` — deliver-on-complete, no look-ahead: the coarse list up to but not including the window that contains `now`. Asking for an unregistered timeframe panics with `timeframe … not registered`.
* `forming_for(tf, now)` returns the still-forming coarse bar up to `now`, or `None` when no base bar has started the current window. Its volume is summed with `vike_model::py_sum`, because the Python it ports used the builtin `sum()`, and its high and low are folded in order — the rule from [the parity gate](/docs/trader/concepts/parity-gate).
## In the engine [#in-the-engine]
The portfolio engine carries the same aggregates per symbol. `EngineParams.timeframes` (`crates/vike-backtest/src/engine/sim_broker.rs`, default empty) names the timeframes to register, and each `SymbolState` holds its `tf` list — "(tf, target\_ms, coarse bars)". `SimBroker::bars_for(symbol, tf)` and `SimBroker::forming_for(symbol, tf)` mirror the single-engine buffer per symbol, cutting the coarse list at the window containing `self.now` and building the forming bar from the live base series, so the reads a strategy makes inside `on_bar` see exactly what the buffer would have shown.
`vike_model::strategy`'s module doc lists multi-timeframe reads among the powers a strategy takes by being written against the concrete `SimBroker` — `impl Strategy` — rather than the portable `impl Strategy`. The type system then documents that the strategy is not live-portable. The portable `Broker::bars` read is the closed base bars up to and including the current step, nothing coarser.
The [multi-timeframe strategy](/docs/trader/tutorials/multi-timeframe-strategy) tutorial reads two synthesised timeframes from one base stream; [the portfolio engine](/docs/trader/backtesting/portfolio-engine) describes the step loop these reads are answered inside, and [authoring in Rust](/docs/trader/strategies/authoring-rust) covers the two forms a strategy can take.
# The vector engine (/docs/trader/backtesting/vector-engine)
`crates/vike-backtest/src/vector_engine.rs` is the multi-asset time×symbol portfolio kernel: the
vectorized fast path for cross-sectional and target-weight strategies, an exact port of the Python
`core/portfolio_fastsim.py::_portfolio_kernel` and `fast_portfolio_backtest`. Where the event
engine steps a strategy object through callbacks, this kernel takes matrices and runs one pass.
## The scope is the parity boundary [#the-scope-is-the-parity-boundary]
The header draws the line deliberately narrowly. In scope is the `cash_gate=False`, market-order,
target-weight path **only**: next-open fills, column-order shared-cash application — cash may go
negative; there is no gate, no reduce-first, no weight priority — taker fees, slippage, funding
and cost-basis averaging. Out of scope, and the reason to use the event engine instead: stops and
brackets, the cash gate, volume and position caps, leverage capping, membership masks,
liquidation, sub-bars and sizers.
The inputs are row-major `Matrix` values of shape (T, S): `opens`, `closes`, `funding` and
`target_weights`, plus the timestamp vector, the fee and slippage scalars, initial cash and one
contract multiplier. `VectorBacktestEngine::run` is the one public entry point; the free function
`fast_portfolio_backtest` is the implementation detail and the parity-gate target. `S = 1` is the
single-symbol case — there is no separate single engine.
## The target-weights contract [#the-target-weights-contract]
`weights[t, s]` is the weight **decided** at bar `t`, from closes up to and including `t`, and
**realized** at `t+1`'s open. `NaN` means "no rebalance decided" for that symbol and bar — nothing
is queued. A weight of `0.0` for a held symbol drives a full close. The last bar's row is never
acted on, because there is no next open to fill at.
Per bar `t`, the kernel does four things in order:
1. **Fill** the deltas queued at `t−1` at `opens[t, s]`, in column order, on the shared cash: the
fill price is the adverse-moved open, the fee is charged on the filled notional, the signed
notional moves cash, and the shared `TradeFold` steps this symbol's cost basis and emits a
closed-portion `Trade` on a reduce, close or flip.
2. **Charge funding** on held positions, marked at the close.
3. **Mark equity** once — a single shared snapshot used for both the curve and the next sizing
decision.
4. **Decide** the next deltas from this bar's target weights: `w · eq / close − pos`, with a
dead-band of `1e-12` below which nothing is queued, and a `close == 0.0` guard mirroring the
Python `_engine_target`'s non-positive-price guard. Skipped on the last bar.
The output is the shared `BacktestResult`: the equity curve, final equity, the trade count and,
when `build_trades` is set, the trade list, plus net funding paid.
## The maker/taker law, and a dead parameter [#the-makertaker-law-and-a-dead-parameter]
Every fill this kernel produces is a rebalance crossing the market at the next bar's open. There
is no resting-limit path, so `is_maker` is always `false` and every fill is charged `taker_fee`.
That agrees with the event engine's own classification — maker if and only if the order kind is
`Limit` — for the same strategy verb this kernel mirrors, `SimBroker::strategy_order_target_percent`,
which only ever submits market rebalances. The two engines are reconciliation partners precisely
because neither has a maker-classified fill on this path.
`crates/vike-backtest/src/vector_engine.rs`'s `fast_portfolio_backtest` records it as a local trap:
the kernel classifies every fill as taker, so its `maker_fee` parameter is dead at every call site.
It exists so a future maker path must consciously wire it rather than silently defaulting to
`taker_fee` by omission. The engine-kernel parity test's fee-schedule-armed row proves the two
engines agree bit-for-bit under a schedule with maker ≠ taker precisely because this kernel never
reads its maker side.
## Reconciliation with the event engine [#reconciliation-with-the-event-engine]
Engine-versus-kernel agreement is a second oracle, gated as a test in
`crates/vike-backtest/tests/parity/engine_kernel_parity.rs`. The kernel shares the event engine's
cost primitives — `adverse_fill_price`, `fee` and `funding_charge` from
[the cost model](/docs/trader/backtesting/cost-model) — and the same `TradeFold`, so the comparison is
of two loops over one arithmetic, not two implementations of it.
The equivalence holds only on the path both engines have. `crates/vike-backtest/src/engine/sim_broker.rs`'s
`EngineParams` documentation names, knob by knob, what the kernel does not consult: the market
impact model (it has no per-fill market context), the funding source closure (it takes funding
as a fully materialized matrix), the binary-resolution and option-expiry settlement sources, the
variation-margin cadence and the latency model. Reconciliation is meaningful only with every one of
those left at its default, which is what the parity gate runs.
## When to reach for it [#when-to-reach-for-it]
If the strategy is a matrix of weights — a rotation, a cross-sectional rank, a fixed allocation
rebalanced on a schedule — and you already hold the aligned open/close/funding matrices, this
kernel answers with one pass and no callbacks. The moment the strategy needs to see a fill, rest
an order, protect a position with a stop, or be told it cannot afford a trade, it belongs on
[the portfolio engine](/docs/trader/backtesting/portfolio-engine).
# Walk-forward (/docs/trader/backtesting/walk-forward)
Walk-forward evaluation runs a strategy on each out-of-sample window of a series and stitches those windows into one equity curve — "the honest way to backtest", in the words of `crates/vike-backtest/src/walkforward.rs`, which is the strategy twin of the original `ml/walkforward.py`: that one wrapped a model, this one wraps any `Strategy` through a caller-supplied `run_window` closure.
## The runner [#the-runner]
`walk_forward_strategy(bars, n_splits, mode, cash, periods_per_year, run_window)` asks `vike_analytics::validation::walk_forward_splits` for the windows, runs `run_window` over each window's test range, and rebases the resulting curves onto one running equity. Each window starts fresh at `cash`; its curve is scaled by `start × (v / cash)` and the running equity advances by `final_equity / cash`, mirroring the Python stitch. The contract, held by a `debug_assert`, is that every window's result starts its equity curve at exactly `cash`.
The report is a `WalkForwardReport`: one `WfWindow { test_range, oos_return }` per window, the stitched `oos_equity_curve`, the overall `oos_return`, `oos_sharpe` over the stitched curve at the supplied annualisation, and `wf_consistency` — the fraction of windows profitable out of sample, the input to the [overfitting](/docs/trader/analytics/overfitting) verdict. Both types are `Serialize` and deliberately not `Deserialize`, so the compute-to-data `RunWalkforwardProfile` verb can return the report as JSON text exactly as `BacktestReport` and `SweepReport` already cross the data-service wire, without coupling the wire schema to this crate's serde surface.
Each `Split` carries a train range as well as a test range, and the runner never reads the train half: `walk_forward_strategy` slices `bars[test_start..test_end]` and nothing else. The harness closure constructs the strategy fresh for every window from the same `strategy.name` and `strategy.params` the profile names, so what walks forward is one fixed parameter set evaluated on successive unseen windows. To have the parameters chosen on the earlier data, choose them with a [sweep](/docs/trader/backtesting/sweeps) first and walk the winner forward — the shape the [sweep and walk-forward](/docs/trader/tutorials/sweep-and-walk-forward) tutorial follows.
## The splits [#the-splits]
`crates/vike-analytics/src/validation.rs` ports the time-series cross-validation splitters of the original `analysis/validation.py` (López de Prado, *Advances in Financial Machine Learning*, chapter 7). `walk_forward_splits(n, n_splits, mode)` divides the series into chunks of `n / (n_splits + 1)` bars: window `s` tests `[s·chunk, (s+1)·chunk)`, the last extending to the end of the series, and train ends where test starts. `WalkMode::Anchored` is the expanding window — `train_start` is always 0 — and `WalkMode::Rolling` a fixed-width train of one chunk immediately preceding the test window. A profile selects between them with `walkforward.mode`, and `anchored` is what an absent key means (this line read "the profile path always uses `Anchored`", which was the default mistaken for the only option). The wider splitter family — purged k-fold, combinatorial purged splits and the duration-parameterised `purged_walk_forward_hours` — is on [validation](/docs/trader/analytics/validation).
## From a profile [#from-a-profile]
A `[walkforward]` table makes a [run profile](/docs/trader/backtesting/run-profiles) walkable, and it must declare **exactly one window form**: a split COUNT (`n_splits`), or DURATIONS (`train` with `test`, and optionally `step`). Declaring neither is refused by name, and so is declaring both — a count is defined by the range and moves when the range moves, while a duration does not, so the two cannot be reconciled by a precedence rule. `crates/vike-backtest/src/harness/walkforward.rs`'s `run_walkforward` walks whichever form the table declares. The module doc calls it assembly rather than new engine code: it resolves the strategy through the same `strategy_by_name` registry, builds the same bar-lane `EngineParams` through `bar_engine_params`, and loads the same bars through `load_profile_bars` as a plain `run_backtest` of that profile — so a walk-forward can never disagree with a single run about what the profile's `[engine]` means. That buys the whole `[engine]` surface — the fee schedule, `[engine.impact]`, `[engine.resolution]`, `[risk]`, `snap_to_properties`, `attach_funding` — where the Studio's DTO-shaped walk-forward (`vike_studio_core::run_walkforward_slice`, served by the `RunWalkforward` wire verb) carries only the three flat `cash`, `fee_rate` and `slippage` scalars.
⚠ **This paragraph read "Only the split count is configurable, deliberately. The mode is always `Anchored` … one knob" and every clause of that is now false.** The table carries **nine** fields: the two window forms above, plus `purge` and `embargo` (legal on the DURATION form only — the split-count walk stays gap-free, [decision 0046](/docs/trader/backtesting/run-profiles)), `mode` (`anchored` | `rolling`; absent = `anchored`, which is where the old claim came from — it is the DEFAULT, not the only value), `search` (`none` | `sweep`; absent = `none`, and `"sweep"` without a grid table is refused rather than silently selecting the one parameter set the profile already has) and `rank_by` (`sharpe` | `return` | `max_dd` | `equity`; absent = `sharpe`). Every one of those, with its default and the sentence that refuses a bad value, is rendered from the type itself on [`backtest --profile`](/docs/trader/reference/cli/research/backtest-profile) — which is why this page no longer lists them by hand.
What still holds: `n_splits` must be at least 1 (`BacktestProfile::validate` rejects 0), the section is `deny_unknown_fields` so a typo fails at load, and an absent section is byte-identical to every profile written before it existed — `run_backtest` and `run_sweep` ignore it exactly as they ignore a `[paramscan]` table they were not asked to expand. It lives in the profile rather than on a wire field because the profile is `deny_unknown_fields`: a TOML carrying `[walkforward]` must parse for the whole file to be shippable verbatim to a remote runner, which is the point of the compute-to-data verb.
The runner is bar mode over one series: `walk_forward_strategy` splits a single `&[Bar]` by index, which a tick replay does not produce, so a tick-mode or multi-series profile is a clean `HarnessError::Validation`, never a silent first-symbol fallback. Every other failure — no `[walkforward]` table, an unresolvable strategy, a store failure, an empty bar slice — is checked before the window loop, because the loop's closure cannot return an error. `EngineParams` is rebuilt per window because it is not `Clone` (it can carry a sizer box and a properties closure), and each window needs a fresh strategy.
The `vike-cli` [walkforward verb](/docs/trader/reference/cli/walkforward) ships a profile to a [data service](/docs/trader/reference/binaries/vike-datahub); [remote runs](/docs/trader/gui/studio/remote-runs) is the Studio's dispatch of the same job.
# A first session (/docs/trader/cli/a-session)
This page is one session, start to finish, on a machine that has just unpacked a release. Nothing
here needs a venue credential. Each step says what it proves as well as what it does, because half
of a first session is establishing that the binary is reading the files you think it is.
## Which binary is this [#which-binary-is-this]
```sh
vike-cli --version
```
prints ` ()` — the shape every `--version` on the box already
prints, with this build's provenance appended by `vike_buildinfo::version_line`. The parenthesised
half answers the question a bare version number cannot: *which commit is this?* A release binary
was once built from a clone four commits behind and nearly installed on a live recorder, which is
why every `--version` in this workspace answers it.
`vike-cli --help` lists the ten verbs. Both exit **0** and print to **stdout**, which is not a given
— four verbs used to route `--help` through their usage-error arm and exit 1 with `help requested`
on stderr. `crates/vike-cli/src/cmd/args.rs`'s `exit_for_parse_error` is now the one place that
decision is made.
## Scaffold the project [#scaffold-the-project]
```sh
vike-cli init
```
`init` creates `/user_data` — the half of the project a program must never author — fills
every folder with a runnable example, and prints the map:
```text
user_data: /srv/vike/user_data
user_data/ your work — nothing here is ever overwritten once you have edited it
strategies/rhai/ interpreted strategies: edit, save, backtest. No toolchain needed.
strategies/rust/ compiled strategies. SOURCE CHECKOUT ONLY — a release binary cannot build them.
indicators/ your own indicators, one .rhai each. Callable from any strategy.
profiles/ run profiles: which data, which costs, which strategy
backtest_results/ saved reports. One sample ships, so the notebooks work before your first run.
notebooks/ analysis over the results above
logs/ compile.log — every strategy load, pass and fail. Look here first.
create strategies/rhai/sma_cross/sma_cross.rhai
...
```
The map is printed on **every** run, including one that changed nothing, because this command is
also how you ask *what is in here again?* — and an answer that only appears the first time is an
answer nobody sees twice. Files that were already in place are counted rather than listed, so on a
re-run the one line that changed is not buried.
Re-running is the ordinary case: a new version ships new examples, so the default writes only what
is **absent** and never touches a file you edited. `--dry-run` performs every comparison and writes
nothing.
`--reset` is the one loaded flag, and its boundary is narrower than "restore the samples" sounds.
It restores every shipped sample that is **missing or edited** — so it *does* overwrite your
changes to a shipped example, which is exactly what it is for. What it can never touch is a file
**you created**: `--reset` compares against one table of shipped files and writes nothing outside
it, and a strategy you wrote is not in that table, so no combination of flags here can delete it.
Reach for `--dry-run` first if you are unsure which of your files are shipped samples — it prints
the same report, marking each one created, restored or kept, and writes nothing. The
[`init` reference](/docs/trader/reference/cli/set-up/init) argues that boundary precisely.
## Prove which files are being read [#prove-which-files-are-being-read]
Two commands, and running them now saves an hour later. Both report on the **same** settings
directory the dispatcher resolved once at startup, taken as a parameter rather than re-derived, so
neither can name a file that nothing else opens.
```sh
vike-cli secrets path
```
```text
store: /srv/vike/settings/secrets.env (present)
```
There is exactly one credential store and this is where it is. If it is absent, the command prints
the three lines that create one — a `mkdir -p`, an `$EDITOR`, and a
`chmod 600 … # it is plaintext credentials` — because creating that file is a person's job with an
editor, never a command's. If its mode grants anything to group or other, a warning goes to stderr;
a finding is never a refusal, so the exit code does not change.
```sh
vike-cli config show --section files
```
prints the settings directory, one line per settings file with whether it was read, and then a
table of every setting with its effective value, the layer that set it, the compiled-in default,
and a `READ` column saying whether anything actually reads it. `--section files` is worth typing:
the environment half is several hundred rows, and the question that motivated the files half —
*did my `policy.toml` get read?* — should not require scrolling past them.
Provenance here is exact rather than inferred: an origin is decided by a key's **presence** in a
parsed layer, so a file that sets a key to the same number as the code default still reports the
file. Until this existed, nothing anywhere printed whether a `policy.toml` had been read at all,
and a typo'd filename produced a silently uncapped node.
## Ask what a strategy may call [#ask-what-a-strategy-may-call]
```sh
vike-cli indicators
vike-cli indicators --name rsi
```
This prints the indicators a Rhai strategy can actually **call** — every row is a name the script
host binds, joined onto its catalog metadata for the label, the parameters and the output lines. The
distinction matters more than it sounds: Rhai resolves a function name when the line *runs*, so a
script naming an unbound indicator compiles cleanly, raises on every bar, and self-disables after
the consecutive-error cap. It looks mounted and silently never trades. A name absent from this
listing is a name that fails exactly that way.
Your own `user_data/indicators/*.rhai` appear in their own block, under their own heading, with no
category — they are a different kind of thing, authored on this box with a file behind them, and
listing only the built-ins told an author their own indicator did not exist. The verb is offline: no
store, no settings, no credential.
## Run a backtest [#run-a-backtest]
A backtest needs no keys. What it needs is a `vike-datahub` server with a history store behind it —
that is the whole compute-to-data trade: the profile's **text** crosses the wire and the tape never
does.
```sh
vike-cli backtest --list-params --script user_data/strategies/rhai/sma_cross/sma_cross.rhai
```
is the offline half — it compiles the script and prints one `name = default` line per
`param(name, default)` call it declares, with no profile, no connection and no server. A script
declaring none says so in one line rather than printing nothing.
Then the run:
```sh
vike-cli backtest --profile user_data/profiles/backtest.toml --addr 127.0.0.1:7878
```
`--addr` defaults to `127.0.0.1:7878`. The server binds loopback, so a remote one is reached over
`ssh -L 7878:localhost:7878 ` and the command line does not change. The profile is read
locally and shipped verbatim; the **server** parses and validates it, which is what keeps exactly
one profile parser in the workspace.
Two flags rewrite the profile on the way out, in a fixed order: `--preset` merges a flat table of
knobs into `[strategy.params]` first, then `--script` injects an authored script's source into
`[strategy.params].src`, so the file on disk always wins over a stale `src` left in the profile.
The report comes back as JSON, pretty-printed for a person. With `--json` the server's bytes are
printed exactly as they arrived — that is the shape a wrapper should consume, and
[Scripting it](/docs/trader/cli/scripting) is the page about consuming it. What each field means is on
[the report](/docs/trader/analytics/report); the profile's own tables are on
[run profiles](/docs/trader/backtesting/run-profiles).
A profile naming a slice the store does not hold completes and reports no trades, which is
indistinguishable from a strategy that never fired. `init`'s own closing lines say so and name the
fix — the `backtest` binary's `--seed-demo` writes a synthetic tape, `--fetch-starter` pulls a
published dataset that needs no venue. `vike-cli` cannot seed a store itself: writing one needs
DataFusion, and this binary has none by construction.
`sweep` and `walkforward` are the same offload over the same wire — a parameter grid and an
anchored out-of-sample validation, both ranked and stitched server-side. From here, the
[trade REPL](/docs/trader/cli/repl) is the other half of the surface, and
[Quickstart: first backtest](/docs/trader/getting-started/quickstart-backtest) is this same path written
as a tutorial with the data-seeding steps in line.
# The daemon and the app (/docs/trader/cli/daemon-and-app)
The thing that trades is [`vike-tradehub`](/docs/trader/reference/binaries/vike-tradehub): a headless
daemon on a server, run as a systemd unit, holding the live core, the venue mounts and the risk
gate. It survives an SSH disconnect because it is not attached to your login session. Everything on
this page is a **client** of that daemon, and none of them holds any authority the daemon does not
grant.
That is the frame worth carrying into the rest of the platform: *the node is the trading process;
the app, the CLI and the agent surface are three ways of looking at it and asking it for things.*
## One node, three clients [#one-node-three-clients]
| client | how it attaches | best at |
| ---------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `vike-desktop --observe ` | the node's observe server, plus local keyless venue feeds | watching. Charts, the DOM ladder, positions and the trade panel over a remote daemon's account plane |
| `vike-cli trade --node ` | observe **and** control, from a terminal | acting, over SSH, on a box with no display — and being able to paste exactly what you did into an incident write-up |
| `vike-cli strategy-status --node ` | observe only, one short-lived connection | answering *what is that daemon actually running?* in one line of shell |
| `vike-cli mcp --node ` | observe and control, exposed as MCP tools | handing the same vocabulary to an agent, behind a preview gate |
The desktop app's remote mode is worth a sentence of its own, because it is not simply "the app,
read-only". `vike-desktop`'s split-plane model divides its data in two: the **account plane** — orders,
positions, equity, bar history — belongs to the backend and arrives as wire snapshots over the
observe bridge, while the **market-data plane** — books, the trade tape, order-flow series — belongs
to the venue and is subscribed directly and keylessly. In `--observe` mode on a full build you get a
remote daemon's account plane beside local feeds, with no local core and no execution engines at
all. [Observe mode](/docs/trader/gui/observe-mode) is that page.
## Two scopes, two keys [#two-scopes-two-keys]
Every one of those attachments authenticates, and the protocol has exactly two scopes.
`VIKE_TRADEHUB_OBSERVE_KEY` opens a read connection; `VIKE_TRADEHUB_CONTROL_KEY` opens a write one.
The node verifies **each scope against its own key**, so a control key cannot stand in for an
observe key — which is why `strategy-status`, a read verb, asks specifically for the observe half
and reports the observe-specific absent-key message rather than a vaguer "no key" line.
Both keys are resolved once, by the dispatcher, for the three node-facing verbs only:
```text
process environment → /settings/secrets.env → nothing
```
The second rung is the same store the daemon itself loads them from, which is the whole point:
before it existed, a box configured exactly as the daemon documents answered *nothing to do*. The
first rung wins because an `export` is ephemeral, narrow and deliberate — it is what makes "point at
a different node for one command" possible — while the store is the box's standing configuration.
`crates/vike-cli/src/cmd/nodekeys.rs` carries that argument, including why this is **not** the
`policy.toml` case: a node key is a credential, not a risk limit, and presenting a different key
cannot widen what an authenticated peer may do. The worst an exported key achieves is failing the
handshake.
Which keys this box holds is answered by [`vike-cli secrets`](/docs/trader/reference/cli/set-up/secrets), by
name and never by value.
## What `strategy-status` reads [#what-strategy-status-reads]
`strategy-status` is the shell-level answer to *what is that daemon running?* It opens one
short-lived observe connection, sends one request, and prints what came back
(`crates/vike-cli/src/cmd/trade_status.rs`). Three things:
```text
node hub-a — paper — build vike-tradehub 0.1.0 (abc1234, clean)
params: spread=0.01 size=20
STRATEGY MODE PARAMS
spread_maker paper venue=polymarket symbol=TOK-A spread=0.01
np LIVE venue=polymarket symbol=TOK-B edge=0.02
```
The **identity line** names the node and the build it is running, down to the commit and whether
that tree was dirty — the same provenance every `--version` in the workspace answers, asked
remotely. The **effective-params line** is what the daemon resolved, not what a file says. And then
one row per mounted strategy, with its mode and its parameters.
The mode cell is uppercase exactly when it matters: `LIVE` shouts, `paper` is the quiet default.
Column widths come from the data, headers included, so a long strategy name cannot shear the params
column. `--json` prints the wire payload verbatim instead — a machine consumer gets the protocol's
own schema rather than a second hand-maintained shape that could drift from it.
Until this verb existed the only way to ask that question from a shell was to compile a throwaway
probe, which is a thing people did.
The verb is feature-negotiated: against a node whose handshake advertises no strategy-verbs
capability, the client sends **nothing** after the handshake and refuses locally. That is
deliberate — an older node's deserializer cannot decode the frame, so sending it would produce an
opaque decode error at the node instead of a diagnosis here. The message names the missing
capability and the one action that fixes it: upgrade that node.
## Choosing between them [#choosing-between-them]
They overlap on purpose, and the useful question is not *which is most powerful* but *what does
this moment need*.
**Take the app** when the question is visual and the answer is a shape — is this fill where the
chart says it is, is the book thinning out, does this indicator behave the way it did in the
backtest. Nothing at a terminal replaces looking at a chart.
**Take the CLI** when the answer has to survive being written down. A `strategy-status --json` in a
cron job, a `trade` session pasted into an incident review with the `--reason` you typed on each
line, a deploy script that refuses to start a unit — none of those have a GUI shape. It is also the
only one of the three that works over an SSH connection to a box with no display, which describes
most trading servers.
**Take the agent surface** when the loop is author, compile, backtest, adjust and you would rather
describe the change than type it. It speaks the same write vocabulary as the REPL — the same
construction site, the same client-order-id mint, the same guardrail — so a verb added to one is
visibly missing from the other rather than silently absent.
Whichever you take, the node's own control limits and risk gate are the enforcing backstop, and the
per-venue arming ceiling in [`policy.toml`](/docs/trader/reference/settings/policy) sits above even the
credential read. A client can only ever ask. See
[the control boundary](/docs/trader/concepts/control-boundary),
[risk and arming](/docs/trader/concepts/risk-and-arming) and
[the node protocol](/docs/trader/concepts/node-protocol) for what the daemon does with what it is asked.
# The command line (/docs/trader/cli)
`vike-cli` is one binary with ten verbs. It scaffolds a project, discloses what this box's settings
resolved to, inspects the credential store, lists what a strategy may call, ships backtests to a
data service, drives a running trading daemon from a terminal, and serves the same trading
vocabulary to an agent over MCP. Every verb is a sibling module under `cmd` exposing a
`run(args) -> ExitCode`, and the dispatcher in `crates/vike-cli/src/lib.rs` never grows command
logic — it only routes.
This section is the narrative half. It says what working at the command line is like and which verb
answers which question; [the CLI reference](/docs/trader/reference/cli) is the authority for every flag,
every exit path and every error string, one page per verb.
## The identity is negative [#the-identity-is-negative]
The most useful sentence about this binary is a list of what is not in it. `vike-cli` links **no
DataFusion and no venue transport stack** — no `ureq`, no `tungstenite`, no `rustls`, no signer.
That is not an accident of what has been written so far; it is the test every dependency in
`crates/vike-cli/Cargo.toml` is argued against, edge by edge, on the manifest row itself.
Three of those arguments are worth carrying, because they explain shapes you will meet later:
* `vike-ops` and `vike-bridge-core` are both taken `default-features = false`. Their default `full`
features carry the venue transport and signing stack, and restoring either would drag it into a
laptop binary that never opens a venue socket.
* the credential store is read through `vike-secrets`, a crate with **zero** dependencies, rather
than through `vike-bridge-core`, the reader's historical home. A `KEY=VALUE` parser is not worth
a TLS stack.
* the client-order-id generator comes from `vike-model` rather than `vike-exec`, its historical
home. `vike-exec` would have added a multi-threaded async runtime and core-affinity to a binary
whose whole identity is being light.
The consequence is the shape of everything else here: **`vike-cli` asks services, it does not run
engines.** A backtest is not computed in this process — the profile's TOML text is shipped to a
[`vike-datahub`](/docs/trader/reference/binaries/vike-datahub) server sitting next to the tape, which
runs it and returns a compact report. Live orders are not placed by this process either — they are
commands sent to a running [`vike-tradehub`](/docs/trader/reference/binaries/vike-tradehub) node, whose
own control limits and risk gate are what actually enforce anything. The heavy history and the
authority both stay where they belong.
## The ten verbs, by the job they do [#the-ten-verbs-by-the-job-they-do]
`crates/vike-cli/src/lib.rs`'s `COMMANDS` is the single source of truth for both the help text and
the unknown-command hint, so a new verb appears in `--help` for free. Grouped by what you are
trying to do rather than alphabetically:
| job | verbs |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| set the box up | `init` scaffolds `user_data/`; `config show` discloses every setting and where its value came from; `config check` judges the same tree and makes the exit code the product; `secrets` inspects the one credential store, and `secrets set` upserts one named key into it — the CLI's only credential writer, value from stdin or an environment variable, never argv |
| do research | `indicators` prints what a Rhai strategy can actually call; `backtest`, `sweep` and `walkforward` ship a profile to a data service and print what came back |
| run and watch | `trade` is the interactive REPL over a live node; `strategy-status` asks that node what it is running, read-only; `mcp` serves the same vocabulary to an agent |
Ten verbs is the whole surface. There is no `clap` dependency behind it: the flag grammar is
hand-rolled in `crates/vike-cli/src/cmd/args.rs` and shared by every verb, which is why
`--flag value` and `--flag=value` work everywhere and why a token beginning `--` is a flag and
never a value everywhere. [Global flags](/docs/trader/reference/cli/global-flags) is that grammar's page.
## What happens before any verb runs [#what-happens-before-any-verb-runs]
Typing any subcommand runs the same three steps first — the first two inside
`crates/vike-cli/src/lib.rs`'s `resolve_policy`, the third in `run`, which calls
`install_user_indicators` only once `resolve_policy` has returned. The order is load-bearing: a
refusal in step 1 returns before an indicator is ever compiled. Together they explain most of what
a fresh install does:
1. **A removed environment variable is refused.** `vike_config::refuse_removed_env` fails the
process, naming the variable, the file that replaces it and the exact TOML line to write. It
fires for every verb, including read-only ones — a stale variable is stale regardless of what
you typed, and an operator who still exports `VIKE_MAX_ORDER_NOTIONAL` believes a ceiling is
armed that nothing reads.
2. **`/settings` is resolved once**, through the workspace's one startup sequence
(`vike_boot::boot`). Every project-relative path any verb uses is derived from that single
answer, so `secrets path`, `config show` and a daemon on the same box cannot disagree about
which project they are in.
3. **Your own indicators are installed process-wide.** `install_user_indicators` compiles
`user_data/indicators/*.rhai` before dispatch, so a script run by any verb can call them. A file
that fails to load is reported on stderr and never fails the command.
The credential store is the deliberate exception to *"resolved once, up front"*: the **dispatcher**
opens it lazily and only for the three node-facing verbs — `trade`, `mcp` and `strategy-status` —
inside their own arms, through `node_keyring`, because those are the three that authenticate to a
node. A backtest has no business opening the file that holds every venue key on the box.
That is a statement about the dispatcher, not about the binary. Two verbs open the store because
the file **is** their subject, and they do it themselves rather than through the deferred boot:
`secrets` reads it directly (`vike_secrets::resolve` for `list`, a `stat` and a permissions check
for `path`, and `set` writes one key through the workspace's one upsert), and `config` does too — `crates/vike-cli/src/cmd/config.rs`'s `execute` loads it before
it prints anything, to report the key COUNT, and `config check` classifies whether it is present,
absent or unreadable. So the honest answer to *"which invocations touch `secrets.env`?"* is: the
three node-facing verbs, plus the two whose job is to tell you about the file.
`vike-cli mcp`'s stdout is a JSON-RPC protocol stream, and one stray line on it corrupts the
session. So every warning, every settings resolution and every rejected indicator goes to stderr —
in every verb, not just that one. It is also why `vike-config` returns its resolutions as data
rather than logging them: a library that writes to its caller's stderr cannot be linked by a binary
whose stdout is a protocol.
## Which of the three surfaces [#which-of-the-three-surfaces]
The platform has three faces over the same core concepts, and they are not ranked — each is best at
something the others are bad at.
Reach for the app when the question is visual — is this fill where I think it is, does this
indicator look right on this chart. Reach for the CLI when the answer has to be repeatable, put in
a shell script, or reached across an SSH connection to a server with no GUI. Reach for the agent
surface when the loop is author, compile, backtest, adjust, and you would rather describe the
strategy than write it. All three end up talking to the same daemon over the same authenticated
protocol, and [The daemon and the app](/docs/trader/cli/daemon-and-app) is the page about that.
# The agent door (/docs/trader/cli/mcp-door)
`vike-cli mcp` is the tenth verb and the odd one out. Every other verb answers a question and exits;
this one stays running and serves the platform as [Model Context
Protocol](https://modelcontextprotocol.io/) tools over stdio, so an agent can drive the same loop
you would drive by hand.
This page is a **door**, not a duplicate. The tool-by-tool reference — every schema, a runnable call
and a captured response — lives under [the Trader MCP server](/docs/ai/trader), and this page
deliberately restates none of it. What belongs here is the part that is a property of the *CLI*:
where the verb sits among the other nine, what it shares with the REPL, and what it refuses to do.
## It is a local subprocess, not a service [#it-is-a-local-subprocess-not-a-service]
```
usage: vike-cli mcp [--addr 127.0.0.1:7878] [--node ] \
[--profile full|read-only|offline] [--deny-tool NAME]... \
[--trace | --trace-dir ]
```
Your MCP client launches this binary as a child process and talks to it over stdin and stdout.
Nothing is listening on a port, nothing is published, and nothing contacts a Vike server — the two
addresses are **your** [`vike-datahub`](/docs/trader/reference/binaries/vike-datahub) and **your**
[`vike-tradehub`](/docs/trader/reference/binaries/vike-tradehub). Omit `--node` and the create-and-
backtest tools still work; the live tools then return a clean error instead.
Something other than an MCP client can be the thing that launches it, and that is how an unattended
session works: `vike-agent-run` starts this same server against your node, hands one prompt to one
model, and leaves a record — read-only unless it is told otherwise, and scheduled by your operating
system rather than by any mode of this verb. It is a separate binary precisely so that `mcp` stays
a subprocess and never grows a scheduler. See [Unattended runs](/docs/ai/trader/unattended).
The transport is hand-rolled over `serde_json`: JSON-RPC 2.0 framed as newline-delimited JSON, one
message per line. That is the same "no async, no new transport stack, stdio-JSON" convention the
rest of this workspace's sidecars use, and it is why an MCP SDK is not a dependency here — it would
drag a second async stack into a binary whose whole identity is being light.
Under `mcp`, **stdout is a protocol**. One stray line on it corrupts the JSON-RPC stream and the
session with it. So the rule is enforced across the whole binary rather than in this one file: the
settings loader returns its resolutions as data instead of logging them, the dispatcher prints them
to stderr, and a rejected user indicator is reported to stderr too. A library that wrote to its
caller's stdout could not be linked here at all.
## One vocabulary, two surfaces [#one-vocabulary-two-surfaces]
Ten of the seventeen tools are read-only — compile a Rhai strategy, discover its knobs, list the
indicators the host binds, run a backtest, a sweep or a walk-forward against a data service, list
what that service holds, and read a live node's snapshot. Seven write to a node: six place, change
or cancel orders and the seventh sets its trading state.
Those seven resolve through the **same** construction site as the [`trade` REPL](/docs/trader/cli/repl):
the same args-to-command mapping, the same client-order-id mint, the same client-side guardrail
(`crates/vike-cli/src/cmd/verbs.rs`). That is structural rather than a convention. Both surfaces
used to build wire commands independently and had already drifted — the REPL supported `modify` and
`mass-cancel` while the agent surface did not, and each carried its own copy of the guardrail. A new
write verb is now visibly missing from whichever surface forgets to expose it, instead of silently
absent from one.
The same is true of the read side: `list_indicators` and the human
[`indicators` verb](/docs/trader/reference/cli/research/indicators) print from the same derivation, so the human
surface and the machine surface cannot disagree about what a script may call — which is the only
property that matters there, because the cost of being wrong is a strategy that mounts and silently
never trades.
## The preview gate fails closed [#the-preview-gate-fails-closed]
Every write tool previews. Calling one returns the resolved command, a guardrail check, the node's
own dry-run verdict where the node can be reached, and a `preview_token` — and sends nothing.
Executing needs a second call carrying `confirm: true` **and** that exact token. The token fires at
most once, expires after sixty seconds, and is **bound to the command it previewed**. Any other
shape — no token, an unknown token, an expired one, a token issued for a different command, or
`confirm: true` on its own — returns a preview and sends nothing.
`confirm: true` used to be the whole test, on that call, so a first-and-only call executed. Nothing
correlated a confirm with the preview it claimed to confirm, so previewing `qty: 0.5` and confirming
`qty: 50` was accepted. And the client-side guardrail it showed cannot price a **market** order —
there is no price to size against — which is the default order type, so the advertised protection
was absent on exactly the path most likely to be taken. The preview now also asks the node for its
own dry-run, and when the node cannot be reached it **says** the verdict is an unverified
client-side estimate rather than presenting an absence as approval.
The node's server-side control limits and the core risk gate remain the enforcing backstop
regardless. The preview is UX; the node gate is truth. See
[the control boundary](/docs/trader/concepts/control-boundary).
## …and a scope that removes the write tools entirely [#and-a-scope-that-removes-the-write-tools-entirely]
The gate above is what a write costs. `--profile` is the prior question — whether this server offers
one at all — and it is a property of the launch command rather than of the conversation. `read-only`
serves the ten read tools and withholds the seven writes; `offline` serves only the four that open
no socket. `--deny-tool NAME` takes out one more, repeatably. A withheld tool is **absent from
`tools/list`** as well as refused, because the advertised roster and the routing gate are two
complements of one pass over the registry (`ToolAccess` in `crates/vike-cli/src/cmd/mcp.rs`) rather
than two lists that could disagree. An unknown profile or deny name is a usage error naming the
valid set — never a quiet widening.
`--trace` is the other half of handing the tools out: one appended JSONL record per tool call under
`/settings/state/agent`, carrying the tool, the redacted arguments and the verdict —
including the **refusals**, which reach no node audit trail because they were refused before a byte
left this process. Off unless asked for, and it records no secret and no token bytes; the
[verb reference](/docs/trader/reference/cli/mcp) has the shape.
## Keys, and what an agent can reach [#keys-and-what-an-agent-can-reach]
The live tools need the same two HMAC keys everything else does, resolved the same way — process
environment first, then the credential store the daemon itself reads. Absent from both, the trade
tools return a clean error naming both places and the create-and-backtest tools keep working, which
is the right degrade: an agent that cannot reach a node can still author and backtest.
What an agent may arm is not an agent question at all. `policy.venues.` is a per-venue
ceiling consulted above the credential read, defaulting to `paper` for every venue — so a box with
no `[venues]` table mounts all paper whatever its store holds, and no tool call changes that. See
[`policy.toml`](/docs/trader/reference/settings/policy) and
[risk and arming](/docs/trader/concepts/risk-and-arming).
# The trade REPL (/docs/trader/cli/repl)
`vike-cli trade --node ` attaches a terminal to a running
[`vike-tradehub`](/docs/trader/reference/binaries/vike-tradehub) node. You type short verbs — `submit`,
`cancel`, `flatten`, `orders`, `positions`, `state` — and they become authenticated commands on the
node's wire. This page is about the experience: what the prompt does, what the confirmation gate is
for, and where the authority actually sits. The [`trade` reference](/docs/trader/reference/cli/trade/trade) is
the verb-by-verb grammar.
## Two connections, opened separately [#two-connections-opened-separately]
The REPL opens **two** connections, and either can be missing. Reads go over an `OBSERVE` handle
subscribed to the node's pushed snapshot stream; writes go over a `CONTROL` handle. Each is gated on
its own HMAC key, and the node verifies each scope against its own key — a control key cannot stand
in for an observe one.
Startup says which halves came up, and where each key came from:
```text
connected: OBSERVE (read) to 10.0.0.5:9200 [key from the credential store]
connected: CONTROL (write) to 10.0.0.5:9200 [key from the process env]
type `help` for commands, `quit` (or Ctrl-D) to exit
vike>
```
The keys are resolved once by the dispatcher, **process environment first, then
`/settings/secrets.env`** — the same store the daemon on the other end loads them from.
`crates/vike-cli/src/cmd/nodekeys.rs` argues that order: the fix is strictly additive, so an
operator who exports the keys today keeps exactly the behaviour they have, and an `export` is
ephemeral, narrow and typed on purpose while the store is the box's standing configuration. Before
that resolver existed this command read `std::env::var` and nothing else, so a box configured
exactly as the daemon documents — both keys in the store, `vike-cli secrets list` confirming them —
answered *nothing to do* and exited.
Values are trimmed on the way in because the server trims on the way out. A client that did not
would sign with `"secret\n"` against a server verifying `"secret"`, and an HMAC mismatch surfaces as
an opaque auth denial rather than as *your key file has a trailing newline*.
With no control key you get observe-only. But a perfectly good key in the store can still leave
writes off, because the **node** refused the control scope — its `tradehub_control` flag is off —
and that refusal happened once, at connect time, before it scrolled away. The three cases are
distinguished at connect and carried forward, so a later write names the true reason: no key
resolved, the node refused the scope, or the connection could not be opened at all. Every message
still ends in "nothing was sent", the one fact all three share.
## Every write previews first [#every-write-previews-first]
Typing a write verb does not send it. The REPL resolves the command, prints it, prints a guardrail
line, and asks:
```text
vike> buy binance BTCUSDT 0.1 @64000 --reason rebalancing the hedge
preview: SUBMIT buy binance BTCUSDT qty=0.1 limit @ 64000 coid=a1b2c3d41
guardrail: qty=0.1 notional=6400 max_qty=- max_notional=250 → OVER LIMIT (the node will reject)
reason (recorded in the node's audit trail): rebalancing the hedge
confirm? [y/N]
```
Only an explicit `y` or `yes` sends anything; Ctrl-C, Ctrl-D, a blank line and anything else are all
a no, which is the safe default for an order write. `--yes` skips the prompt — and note that the
preview still prints even then, so the human watching a scripted session sees exactly what went.
Two details in that preview are worth knowing about.
**The client order id was minted by this side.** A node refuses a remote submit whose
`client_order_id` is empty — a remote peer whose id the runtime minted has no stable handle to
cancel or deduplicate by — so every client of that protocol must pre-mint one. The generator is
session-scoped: an eight-hex prefix from OS entropy per process, then a counter, so two REPLs open
side by side, or one restarted while an order still rests at the venue, cannot collide. The id is
filled in **before** the preview renders, so the id you read is the id sent and the id you type back
at `cancel`. `submit … --coid ` pins one explicitly.
**The numbers are rendered, not dumped.** `0.4 * 3` is exactly `1.2000000000000002` in binary
floating point, and `f64::to_string` is obliged to print all of it. The preview instead prints the
shortest fixed-point rendering that still round-trips within a display tolerance, because a reader
who has learned to skip past seventeen digits of arithmetic noise is a reader who will skip past a
real discrepancy in the same position. The comparison itself is done on the raw values —
`crates/vike-cli/src/cmd/verbs.rs`'s `guardrail_check` never reads the string.
## What the guardrail is, and what it is not [#what-the-guardrail-is-and-what-it-is-not]
It is **advisory**. `guardrail_check` compares the order against two local caps: this box's
`max_notional_per_order` from [`policy.toml`](/docs/trader/reference/settings/policy), and
`VIKE_MAX_ORDER_QTY`, a local typo-catcher with no policy field and no enforcing counterpart
anywhere. An `OVER LIMIT` verdict does not stop the send — it warns you that the node will refuse
it.
The enforcing gate is on the node: its server-side control limits and the core risk gate. The
client preview is UX, the node gate is truth. See
[the control boundary](/docs/trader/concepts/control-boundary) and
[risk and arming](/docs/trader/concepts/risk-and-arming).
The default order type has no price, so there is no notional to compute and the notional half of
the check is skipped. The quantity half still runs. This is the branch most likely to be taken and
the one where a client-side estimate is weakest — another reason the node's own gate is the one
that counts.
The notional cap deliberately does not come from the environment. It used to be
`VIKE_MAX_ORDER_NOTIONAL`, and that variable was removed rather than deprecated: a ceiling any
exported variable can raise is not a ceiling. Setting it now **refuses startup**, with a message
naming `policy.toml`, the key, and a paste-ready line carrying your own value.
## Each command reports its own outcome [#each-command-reports-its-own-outcome]
After sending, the REPL waits up to two seconds for the node's verdict on **that** command and
prints one of four things: accepted (with the coid), rejected with the node's reason, an explicit
*not confirmed — the connection dropped before the node answered*, or *sent, but the node has not
answered within 2s*. It is a deadline rather than a sleep: the answer returns the instant the reply
lands, which is sub-millisecond on loopback.
This used to poll a handle-wide error field that **latches**. After one refusal every later write in
the session printed that same refusal, including commands the node had accepted and executed. An
operator who fat-fingered an order, got a correct refusal, then typed `market-exit` was told the
panic button had been rejected when it had run. The outcome is now awaited on the ticket the send
returned, which is that command's identity and no other's.
## The small comforts [#the-small-comforts]
`--reason ` may end any write line. It is split off before the verb grammar
sees it, shown in the preview, sent beside the command, and recorded in the node's audit trail — so
an incident review reads *why*, not just *what*. It never becomes part of the order, and the node
sanitizes it before recording.
Ctrl-C abandons the current line and keeps the session; Ctrl-D quits. Line history persists under
the settings directory's `state/` sub-directory, best-effort — with no project above the working
directory it is simply not persisted, which is a quiet degrade rather than an error.
`help` prints the verb reference, and it is the one place in the binary where `--help` does not
short-circuit through the usage-error channel: the REPL's help is a reference to an interactive
grammar rather than a usage line, so it is recorded and rendered instead.
The two procedures this REPL exists for are
[stop, liquidate, restart](/docs/trader/guides/stop-liquidate-restart) and
[triage a stuck order](/docs/trader/guides/triage-a-stuck-order). The read-only question — *what is that
daemon running?* — has its own verb, on [The daemon and the app](/docs/trader/cli/daemon-and-app).
# Scripting it (/docs/trader/cli/scripting)
Nine of the ten verbs are non-interactive, which makes `vike-cli` a reasonable thing to put in a
cron job, a deploy script or a systemd unit. This page is the contract a wrapper can rely on: what
comes out, on which stream, and what the status code means.
## Six verbs speak JSON, across seven surfaces [#six-verbs-speak-json-across-seven-surfaces]
`--json` exists on `backtest`, `sweep`, `walkforward`, `strategy-status`, `indicators`, `config
show` and `config check` — seven places to type it, six of the ten verbs, because `config` carries
the flag twice, once on each subcommand. It is a bare boolean everywhere — `args::no_value` rejects
an inline value rather than reading `--json=1` as a silent true — and both flag spellings work on
every valued flag beside it, so `--addr 1.2.3.4:7878` and `--addr=1.2.3.4:7878` are the same thing.
What `--json` actually emits differs in a way worth knowing before you write a parser:
| verb | what you get |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `backtest` | the **server's** report bytes, printed exactly as they arrived. Without `--json` the same JSON is re-parsed and pretty-printed for a person |
| `sweep`, `walkforward` | the server's ranked or stitched report JSON, same idiom |
| `strategy-status` | the node's wire payload, serialized verbatim — `identity`, `effective_params`, `mounts` |
| `indicators` | machine-readable rows instead of the grouped listing, with your own indicators under their own key and no `category` field |
| `config show` | one object carrying the settings directory, the per-file status, both row sets and the loader's warnings |
| `config check` | one object with `settings_dir`, `settings_dir_origin`, `strict`, `ok`, `failures`, `warnings` and one entry per finding |
The pattern in the first three rows is the rule rather than a coincidence: where a server computed
the answer, the machine view is **that server's own schema**, not a second hand-maintained shape
that could drift from it. The same discipline is why no metric is recomputed on this side — ranking
and every printed statistic in a sweep are the server's numbers.
`config check --json` is the one that pre-computes its own verdict. `ok` is the exit code as a
boolean, deliberately: a reader must not have to re-implement the `--strict` promotion rule to learn
what the status was going to be.
Four verbs have no `--json`, and each absence is a decision. `secrets` prints key **names** and
never values, and its output is a disclosure meant to be read and pasted into an issue. `init`
prints a map for a human who has just installed something. `trade` is a REPL and `mcp` is already a
protocol.
## Two exit codes [#two-exit-codes]
Every verb exits **0** or **1**. There is no ladder of numeric codes to switch on, so a wrapper
branches on success and reads stderr for the rest.
| outcome | code | stream |
| -------------------- | ----- | ----------------------------------------------------- |
| the verb did its job | 0 | the product on stdout |
| `-h` / `--help` | **0** | the usage on stdout |
| `--version` / `-V` | 0 | stdout |
| a usage error | 1 | `vike-cli : ` plus the usage, on stderr |
| the verb failed | 1 | the message on stderr |
| an unknown command | 1 | the error on stderr, then the help |
| no subcommand at all | 1 | the help — there was nothing to do |
`crates/vike-cli/src/cmd/args.rs`'s `exit_for_parse_error` is the one place the help-versus-error
decision is made, which is not a detail: four verbs used to route the help short-circuit into their
usage-error arm and exit **1** with `help requested` printed to stderr as though it were a
diagnostic. A non-zero `--help` breaks every wrapper that checks a status, and `config` had already
been fixed at its verb level and not at its `show --help` level, in the same file. The shipped-binary
gate is `crates/vike-cli/tests/help_cli.rs`, which runs the real binary and asserts the status, the
stream, and that no internal token leaks.
## The stream split is a contract [#the-stream-split-is-a-contract]
**stdout is the product; stderr is everything else.** That holds in every verb, not only under
`mcp` where stdout is literally a JSON-RPC stream. The settings loader's non-fatal resolutions, a
credential-store permissions warning, a rejected user indicator, and every failure message go to
stderr.
So `vike-cli backtest --profile p.toml --json | jq '.sharpe'` is safe, and `2>/dev/null` around it
is safe in the narrow sense that stdout stays clean — but you will have thrown away the line that
tells you a preference was clamped or that your credential file is world-readable. Redirect stderr
to a log, not to the void.
## Branching on it [#branching-on-it]
The obvious use, and the one the shipped units actually make, is `config check` as a pre-flight:
```sh
if ! vike-cli config check; then
echo "this box is misconfigured; not starting" >&2
exit 1
fi
```
That is precisely what `ExecStartPre=` in the shipped service units runs. The exit code **is** the
product of that verb: a settings tree that fails to load, a removed environment variable still set,
or a `VIKE_SETTINGS_DIR` naming a path that is not a directory each fail it, while an absent
credential store — the ordinary live gate — does not.
Run it with `--strict` when *you* are asking whether the box is fully configured; never in a unit.
`--strict` promotes every **warning** to a failure, and a correctly installed box legitimately
carries warnings: a 0644 store, which `vike_secrets::PermissionWarning` argues must be reported
rather than refused, or the `/.env` a shipped recorder unit uses as its `EnvironmentFile`,
which is the same file to `vike_secrets::legacy_store_warning` as a pre-one-store leftover. An
absent store is **not** one of them — `crates/vike-cli/src/cmd/config_check.rs` classifies it `ok`
outright, and `an_absent_credential_store_is_not_even_a_warning` pins that not even `--strict` may
fail a paper deployment over it.
A monitoring check over a live node is the other common shape:
```sh
vike-cli strategy-status --node "$NODE" --json |
jq -e '[.mounts[] | select(.live)] | length > 0'
```
Each mount row carries `strategy`, `params` and a boolean `live`, and the identity block carries its
own `live` — the daemon's process-wide arming gate. Those two **disagreeing** is the normal,
informative case rather than a contradiction: the daemon is armed and this particular mount is not,
because its venue holds no credentials or its connect demoted it to paper. A monitor that reads only
one of them will eventually be surprised by the other.
and a nightly research run is the third:
```sh
vike-cli backtest --profile profiles/backtest.toml --json > "reports/$(date +%F).json"
```
`refuse_removed_env` runs before dispatch, so a script that exports `VIKE_MAX_ORDER_NOTIONAL`
anywhere in its environment gets exit 1 out of `secrets path` as readily as out of `trade`. That is
deliberate — a variable that is stale is stale regardless of the verb, and an operator who set it
believes a ceiling is in force. The message names the file, the key and a paste-ready TOML line
carrying their own value; the fix is to write that line and unset the variable.
## Two things that make a run reproducible [#two-things-that-make-a-run-reproducible]
`--profile` ships the file's **text**, so what ran is exactly what is on disk and in your version
control — there is no server-side profile library to drift from it. And `--list-params` is offline:
```sh
vike-cli backtest --list-params --script strategies/rhai/rsi_meanrev.rhai
```
compiles the script and prints one `name = default` line per tunable knob with no profile, no
connection and no server, which makes it cheap to assert in a pre-commit hook that a script still
declares the parameters a profile sets.
For what the report's fields mean, see [the report](/docs/trader/analytics/report); for the flag grammar
every verb shares, [global flags](/docs/trader/reference/cli/global-flags).
# When it goes wrong (/docs/trader/cli/troubleshooting)
Most `vike-cli` failures are one of three shapes, and all three are the binary telling you something
true about the box rather than about itself. This page takes them in the order a fresh install meets
them. The platform-wide symptom index is [Troubleshooting](/docs/trader/troubleshooting); this page is the
CLI's own.
Two rules explain the dispositions below. **Absent credentials are the live gate** — a venue with no
keys stays paper, silently, by design. And a misconfiguration splits two ways: **refuse** when a
silent difference would leave you believing a protection is armed when it is not, **degrade** when
"unconfigured" is a state you may legitimately have chosen.
## `no store found — every venue stays paper` [#no-store-found--every-venue-stays-paper]
```sh
vike-cli secrets path
```
```text
store: /srv/vike/settings/secrets.env (absent)
nothing here reads any other location — create it to configure a venue:
mkdir -p /srv/vike/settings
$EDITOR /srv/vike/settings/secrets.env
chmod 600 /srv/vike/settings/secrets.env # it is plaintext credentials
```
This is the ordinary unconfigured state and it is deliberately not an error. What it usually means
on a box that *is* configured is that the walk resolved a different project than you expected: the
settings directory is found by walking **up** from the working directory, so the answer depends on
where you were standing. `VIKE_SETTINGS_DIR` names it outright and beats the walk. Nothing outside
that directory can supply a credential — no home directory is consulted under any variable.
`Path::exists` maps every error to `false`, so a project root the process cannot search once
reported the store as *absent* and then printed the fresh-install advice for what was really a
permissions problem. `crates/vike-cli/src/cmd/secrets.rs`'s `Presence` uses `Path::try_exists`
instead, and the third state prints words the other two share none of, because an operator greps
this line. It is a finding rather than a refusal: it names the path, says what was not established,
and still exits 0 — `path` is what you run when something is already broken.
A store that **exists and cannot be read** is a different problem again, and one the platform treats
as an error rather than as "no credentials": those two states looking the same downstream is exactly
how a permissions bug wears the fresh-install answer while every venue drops to paper. For the
node-facing verbs the read is non-fatal — the error is reported on stderr and the store treated as
empty, because an exported key must still work when that file is broken — while
`vike-cli config check` escalates it to a **failure** on a box armed for live, and leaves it a
warning on one that is not.
## `cannot connect to datahub at 127.0.0.1:7878` [#cannot-connect-to-datahub-at-1270017878]
```text
vike-cli backtest: cannot connect to datahub at 127.0.0.1:7878: Connection refused (os error 111)
```
`backtest`, `sweep` and `walkforward` are clients. They compute nothing locally — the whole design is
that the profile's text crosses the wire and the tape does not — so with no server there is nothing
to fall back to. Three causes, in the order they happen:
1. **No `vike-datahub` is running.** Start one next to the history store.
2. **It is running somewhere else.** `--addr` defaults to `127.0.0.1:7878`; the server binds
loopback and refuses a non-loopback bind unless it is explicitly allowed and a node key is set.
Reach a remote one with `ssh -L 7878:localhost:7878 ` and leave the command line alone.
3. **The tunnel is up but the far end is not.** A refused connection through a forward looks
identical to a refused local one.
A transport failure and a server-side error both arrive as the same kind of failure on this side, so
read the message: a server that answered and refused says so in its own words. A profile with no
`[sweep]` table, or a walk-forward over a tick or multi-symbol profile, are clean **server-side**
errors rather than silent fallbacks.
The other half of "it connected and did nothing useful" is an empty store: a profile naming a slice
the store does not hold completes and reports **no trades**, which is indistinguishable from a
strategy that never fired. That is not an error anywhere, and it is why `init`'s closing lines name
the seeding commands.
## A removed environment variable refuses startup [#a-removed-environment-variable-refuses-startup]
```text
vike-cli: VIKE_MAX_ORDER_NOTIONAL=250 is set, but VIKE_MAX_ORDER_NOTIONAL is NO LONGER READ —
removed in Phase 5 (settings unification), because an order-size ceiling any exported variable can
raise is not a ceiling.
Set it in /settings/policy.toml instead:
max_notional_per_order = 250
then unset VIKE_MAX_ORDER_NOTIONAL.
```
Every verb runs this check before dispatch, so `secrets path` fails on it exactly as `trade` does.
That is the point: a stale variable is stale regardless of what you typed, and quietly ignoring it
would let the box trade uncapped while the operator believed a ceiling was armed — worse than either
keeping the variable or refusing.
The message pastes back your own value when it is a value the key would accept, and a placeholder
when it is not, so the suggested line always works. A variable whose value is itself a secret is
named but never echoed. A **present but empty** value starts normally: it configured nothing under
the old reader either, so nobody can believe a ceiling was active from one.
`vike_config::REMOVED_ENV` is the table, and it currently holds `VIKE_MAX_ORDER_NOTIONAL`,
`VIKE_TRADEHUB_MAX_ORDER_NOTIONAL` and `VIKE_SECRETS_PASSPHRASE`.
## Node keys and scopes [#node-keys-and-scopes]
```text
vike-cli trade: no vike-tradehub node key found — nothing to do.
looked for VIKE_TRADEHUB_OBSERVE_KEY (read) and VIKE_TRADEHUB_CONTROL_KEY (write) in:
1. the process environment (a shell `export`, a systemd `Environment=`)
2. ...
set at least one to observe/control the node — `vike-cli secrets path` prints the store's location.
```
The message names **both** places it looked, which the old one did not — it claimed the keys were
"not set in the environment" while the daemon's own keys sat unread in the credential store. If you
are sure the keys are there, `vike-cli secrets list` prints the names it can see in the store, and
`secrets path` prints which store that is.
Three more shapes are worth recognising because they look like a key problem and are not:
* **`writes disabled: the node REFUSED the control scope at connect time`.** Your key is fine; it
authenticated. The node is not accepting control connections at all — turn `tradehub_control` on
in *its* `flags.toml` and restart it.
* **A read verb refusing while a control key resolved.** The node verifies each scope against its
own key, so a control key cannot authenticate a read. `strategy-status` says so explicitly rather
than printing the generic "no key found", because *the key is right there in the store* is exactly
what you would otherwise conclude.
* **The node advertised no strategy-verbs capability.** That is a version gap refused on the client
side before anything is sent, so the fix is to upgrade that node's `vike-tradehub`.
## Usage errors that name the wrong token [#usage-errors-that-name-the-wrong-token]
The flag grammar refuses to let a valued flag eat another flag. `vike-cli backtest --script --json`
does not silently take `--json` as the script path; the error names the unfed flag **and** the token
it would have eaten. `--profile` with nothing after it is `--profile requires a value`, and
`--json=1` is `--json takes no value` rather than a silent true. The test is `--`, not a bare `-`,
because a negative number is a real value — `--qty -5` resolves to `-5`.
One class of message is not an error at all: a line like `vike-cli: : …` about an indicator is
`install_user_indicators` reporting a `user_data/indicators/*.rhai` it could not compile. It goes to
stderr and never fails the command, deliberately — one half-edited indicator blocking every
invocation, including the `init` that scaffolds the examples, would be worse than the report. But
from inside a strategy that indicator is indistinguishable from a typo in the call, so this is the
only place that says which.
For the whole box rather than one verb, `vike-cli config check` judges the settings tree and the
credential store together and makes the verdict its exit code — see
[Scripting it](/docs/trader/cli/scripting) and [`config`](/docs/trader/reference/cli/set-up/config).
# The account (/docs/trader/concepts/account)
`crates/vike-exec/src/account.rs`'s `Account` is the money-side read model of one venue account:
positions and realized PnL derived from the fill stream, plus cash, fees, funding and the mark
slot equity is valued at. Its header lists the contract in one block, and this page is that block
expanded.
## One writer, no cache, fixed fold order [#one-writer-no-cache-fixed-fold-order]
`Account` is per-venue — `apply_fill` asserts the fill's venue matches its own — and keys positions
on `(venue, symbol, position_side)`, with `Both` for one-way and spot and the side reserved for
hedge-mode perpetuals. A `PositionEntry` is a signed `size` and an `avg_px`, plus a margin-mode
carrier so a position *can* be isolated. `fold` is the **sole writer** of position and realized PnL:
both `apply_fill` and `apply_liquidation` route through it, and it delegates the arithmetic to
`vike_model::compute_fill` — the one cost-basis primitive, whose doc forbids reordering an
operation or "simplifying" an expression — so the backtest engines and the live read model can never
drift about what open, add, reduce, close and flip mean. A position opening from flat takes its
*asset's* ruling margin mode rather than the type default, because one venue publishes
isolated-only per asset and a bare default booked every new position cross with nothing downstream
ever correcting it.
There is **no equity cache anywhere** — the header names catastrophic cancellation as the risk —
so every read is a sparse full recompute. `equity_all` is mode-aware: under `BalanceMode::Delta`
it is seed + balance + realized + Σ unrealized; under `Authoritative`, set when a venue's
`AccountState` assigns the balance absolutely, it is balance + Σ unrealized, because the venue's
number already folds the rest in. The sum is `vike_model::py_sum` over positions in insertion
order, and that is why every map on this type is an `IndexMap` and **never a `HashMap`**: the
insertion order *is* the `f64` fold order the [parity gate](/docs/trader/concepts/parity-gate) pins
against `fixtures/r0/account_scenarios.json`. `margin_in_use` is the same discipline applied to
margin — one fold that had been hand-rolled four times and had drifted on the rate fallback; only
the rate policy differs per caller now.
## The mark slot has one writer and a precedence law [#the-mark-slot-has-one-writer-and-a-precedence-law]
`Account.marks` is a single untagged scalar per symbol, read by the pre-trade risk gate, the
[margin-call](/docs/trader/concepts/margin-call) law, the trailing-stop seed and what a mounted strategy
calls "the price". Several feeds write it at wildly different cadences, so `set_mark_from` — the
only way in; the field is private after the law was bypassed three times — carries the rule: while
a symbol's slot holds a genuine venue mark observed within its source's ownership window, a
bar-close or trade-tick write is dropped; otherwise last-write-wins. A streamed mark owns for
`DEFAULT_MARK_STALENESS_MS` (10 s, about ten missed ticks); a mark sampled by reconciliation owns
for `DEFAULT_RECONCILE_STALENESS_MS` (150 s, sized past the default reconcile interval so the slot
does not alternate between passes). The same writer is the hostile-venue guard: a non-finite mark
is refused, and the doc explains why `px > 0.0` is not that check — `+inf` passes it and turns
equity into NaN, where every risk comparison reads false. The concepts the resolver drops still
fill their own [price board](/docs/trader/concepts/price-board) slots.
## The fill-dedup ledger lives on the aggregate that owns the money [#the-fill-dedup-ledger-lives-on-the-aggregate-that-owns-the-money]
`apply_fill` moves money — commission out of cash, fees accrued per asset, the position fold,
realized PnL — so it is not safe to call twice for one venue execution. The header records why the
guard moved here from the engine: the number of paths that re-deliver an already-folded fill kept
growing, and each is a producer that *knows* it duplicates — bybit's fast-then-authoritative
execution pair, binance perp's early fill and its twin sharing one id, hyperliquid's whole
`userFills` snapshot re-sent on every reconnect, alpaca's `since_id` boundary, the resync and replay
lanes. `Account` owns the set, consults it before anything moves, and reports a `FillFold`:
`Applied`, `Duplicate` (nothing moved, `duplicate_fills_refused` advanced — a large value here is
*normal* on those venues), or `Collision`.
Venue trade ids are per-symbol sequences on several venues, and the paper client's counter is
per-client, so two different executions can carry the same id string. Widening the key would be
the move that loses money: the reconciliation diff compares venue-reported ids as bare strings, so
a composite key would stop matching there and an already-folded fill would come back as a
`MissingFill` and be folded a second time — the exact double-count the ledger exists to prevent.
So the key stays bare, and each entry stores an FNV-1a fingerprint over `(symbol, side, last_qty,
last_px)`. Same id and same fingerprint is a re-delivery, refused silently. Same id and a
**different** fingerprint is an id collision — a genuine fill about to be dropped — counted into
`colliding_fills_refused` and logged at error naming both fills. It is still refused, because
folding on a mismatch would make the guard depend on venue rounding being stable; any nonzero
value on that counter is a fault, and it understates the position. Reconcile the venue.
Two properties of the set are deliberate. It is **unbounded**: every capped alternative re-admits a
duplicate the moment a replay window reaches back past the cap, and the widest window here is a
one-hour lookback plus row-count-bounded venue history endpoints, not a fill count. And it is a
**within-session** guarantee: `crates/vike-exec/src/account.rs`'s `seed_seen_fill_ids` records that
seeding is the caller's job and no shipped binary does it, so a fresh process starts empty and a
replay lane reaching before the restart re-folds prior sessions' fills; a replay lane's spawn-time
floor and this ledger are independent, so neither substitutes for the other. An empty trade id is
unrepresentable: `FillEvent::trade_id` is a `TradeId` whose constructor refuses the empty string.
## The other folds [#the-other-folds]
`apply_funding` nets a periodic funding cashflow into cash. `apply_account_state` sets the balance
**authoritatively** from a venue snapshot — the quote-asset row if present, else a single balance,
else the `py_sum` of all — and flips the mode. `apply_liquidation` closes `min(reported, held)` at
the liquidation price through `fold` — a reported qty of exactly `0.0` closes the WHOLE held size,
the Python truthiness it ports — clamped so an over-reported frame can never flip the position,
and deducts the fee. What each event carries is one page each under
[events](/docs/trader/concepts/events/); which divergences reconciliation folds back into this account
is under [reconciliation](/docs/trader/concepts/reconciliation/).
`snapshot` and `restore` round-trip the account for the journal's `Snap` record and for
[replay](/docs/trader/concepts/journal/replay). Five things are deliberately not restored: the
per-asset ruling margin-mode grid (mount configuration rather than folded state — every restored
position keeps its own margin mode, but an open-from-flat after a bare restore reads an empty grid,
so a live restore path has to re-apply it), the balance-sync realized-PnL baseline (cleared, so the
first reconcile pass re-adopts venue cash), mark provenance (a restored slot is unowned, and the
next writer takes it), the two refusal counters (live health signals, not folded state), and the
dedup ledger itself — its durable home is the engine snapshot's `seen_trade_ids`, seeded back
immediately after restore, which is what kept the journal wire and its `state_hash` fence
byte-identical when the set moved onto this type.
# Accounts (/docs/trader/concepts/accounts)
A venue used to have exactly one credential set per tier, because the key names said so: the
grammar is `{VENUE}_{TIER}{SUFFIX}` and there was no place in it for a second account.
`crates/vike-model/src/account_keys.rs` opens with the requirement that changed that — an operator
may hold twenty hyperliquid accounts, demo and live, and wants every one visible and independently
armable.
## The key grammar, and why the label goes at the end [#the-key-grammar-and-why-the-label-goes-at-the-end]
The obvious spelling, `{VENUE}_{TIER}_{LABEL}{SUFFIX}`, is unparseable against keys that already
exist: several bespoke suffixes are multi-word, so `OANDA_DEMO_ACCOUNT_ID` would read equally well
as tier `DEMO`, label `ACCOUNT`, suffix `_ID` — a real live-credential key silently reclassified as
somebody's account. So the label is appended after the whole of today's key, behind a separator the
existing grammar cannot produce:
```
{VENUE}_{TIER}{SUFFIX} the DEFAULT account — unchanged, byte for byte
{VENUE}_{TIER}{SUFFIX}__{LABEL} a LABELLED account
```
`ACCOUNT_SEPARATOR` is a double underscore. That no existing key contains one is checked rather
than assumed — `no_existing_credential_key_contains_the_separator` folds the enumerable key grid and
sweeps the bespoke literals out of every bridge's source — so the parse is total: split at the first
`__`, the left half is today's key verbatim, the right half is the label, and it stays correct for
a suffix invented tomorrow. A label is `[A-Z0-9]+`, at most `MAX_LABEL_LEN` (24) characters because
it becomes part of a lock filename, and `DEFAULT` is refused by name since it would be a second,
silent name for the account an unlabelled key already addresses. `AccountLabel::parse` is
case-sensitive and does not repair: `alt` is refused, not uppercased, because a spelling the loader
fixes is a spelling nobody learns.
`AccountRef::route_key` is the identity a process addresses an account by — the bare venue id for
the default account, exactly what `ExecutionEngine::new` already seeds, and `venue#LABEL` for a
labelled one. It is the engine's `route_key`, the name of the `LIVE-.lock` sentinel
`vike_ops::live_lock` claims for one live process per venue account, and the key the journal
records; the tier is deliberately not in it. `accounts_in_store` derives every account from the key
names in the credential store, so the enumeration is never a list anybody maintains.
## The per-account ceiling inherits nothing [#the-per-account-ceiling-inherits-nothing]
`policy.toml` gains an `[accounts]` table beside `[venues]`, and `VenuePolicy::account` resolves an
account's ceiling as `min(the venue's line, the account's own line)`. The asymmetry between the two
defaults is the whole safety argument: the **default** account with no line of its own inherits the
venue line exactly, so today's files resolve unchanged; a **labelled** account with no line of its
own resolves `paper`. A `bybit = "live"` line was written when bybit had one account, and reading it
as consent for an account that did not exist then is precisely the silent escalation the
[ceiling](/docs/trader/concepts/risk-and-arming) exists to prevent. The venue line caps both cases, so
`bybit = "paper"` disarms every bybit account whatever their own lines say.
`vike_mount::venue_account_arming` is the per-account projection — the rows the Data Manager renders
and the selection the mount fans out from, one function so the "Effective" column cannot disagree
with what the mount does. `known_accounts` unions the credential store (which accounts exist) with
the `[accounts]` table (which have a stated ceiling): an account with keys and no line appears as
`ArmingBlock::AccountNotNamed`, which tells the operator how to arm it, and an account with a line
and no keys appears as `NoCredentials`, so a typo'd label cannot vanish. `ArmingBlock::NoAccountSupport`
names a venue whose mount arm cannot address a second account at all; `vike_mount`'s
`arm_addresses_accounts` is the authority for that set, and its doc says one venue remains,
dukascopy.
`make_engine_accounts` mounts one engine per active account. The default account is always mounted,
paper included; a labelled account is mounted only when its ceiling permits something above paper
**and** its credentials load — a paper engine for an account nobody armed would be a second local
book nobody asked for. Result order is a contract: default first, labels in order. Each account is
mounted on its own symbol, and a strategy mount names the account it trades on
(`vike_exec::MountSpec::account`, which crosses the wire and is persisted with the
[runtime topology](/docs/trader/concepts/mounting)); the default account is spelled as an absent field,
so an account-less mount's bytes are unchanged.
## The shared-book rule reports and never refuses [#the-shared-book-rule-reports-and-never-refuses]
`crates/vike-config/src/venue_accounts.rs` states a correction first: the symbol is **not** part of
the rule. An earlier version refused two active accounts armed on one symbol, and the thing it
refused is an ordinary spread — long on account A, short on account B. Two accounts are two wallets;
the venue nets nothing between them.
The real hazard is two of this process's engines pointed at the **same venue book** — an agent key
signing for a master whose own key is also configured, or one credential set pasted under two
labels. Each engine reads "I hold 1" while the venue holds 2, and reconcile's auto-applied
[`PositionDrift`](/docs/trader/concepts/reconciliation/position-drift) rewrites each onto a total that
includes the other's. `shared_books` finds every pair of distinct, active accounts of one venue
resolving to one book, and `make_engine_accounts` emits one warning per pair naming the venue, both
labels and the book — and mounts both. The operator wrote both credential sets by hand under
explicit labels; a refusal would strand a venue on paper over a configuration they may have meant,
while a line naming the two labels lets them recognise a paste error in seconds. `paper` is not
active, since the paper exchange fills locally; `demo` is, because a demo account is a real book
with real rejections.
`vike_mount::book_identity` answers per venue. An EIP-712 venue derives its wallet address from the
private key in the store, so the answer is exact and offline; a key/secret venue's store holds an
HMAC key and nothing that names an account, so it contributes no `ArmedBook` and no pair can form —
a placeholder would compare equal to every other unknown and manufacture a pair. The declared
residual: the same api key and secret pasted under two labels on binance, bybit, okx or deribit is
the same book and the table says nothing, because a row may only name values that are safe to log.
One more thing follows the account rather than the venue: `Event::AccountState` names no symbol, so
`account_event_sender` scopes each account's exec-event lane to its route key, and a second
account's balances land in its own book rather than the venue's default engine.
# Alerting (/docs/trader/concepts/alerting)
`vike-alerting` exists because of a loss. prod1 crashed and roughly six hours of recorded Polymarket
L2 tape were lost unnoticed; the wanted fix was a watchdog that pages when a recorder stops writing,
with no business linking the trading core. Inside `vike-ops` it would have had to link `vike-core`,
`vike-exec` and `vike-model` just to post a message, so the crate was split on the seam that was
already there. A default build has **zero** `vike-*` dependencies, and the gate is `cargo tree`,
not the feature list: the `alerting-standalone` lane builds the default configuration alone and
fails if any `vike-` line appears. Only the two inputs that name a vike type — a `CoreSnapshot` and
a typed `Event` — sit behind the `core` feature. The whole thing is an off-fold consumer of
published state: the headless daemon's summary thread hands the lossy snapshot to the engine once
per tick, and nothing here back-pressures or blocks the [core](/docs/trader/concepts/execution-core).
## Rules [#rules]
`rule.rs` is pure data and serde. An `AlertRule` has a stable `id` (the evaluation-state key, the
cooldown key and the delivery correlation id), a `name`, an `enabled` flag, a `trigger`, delivery
`targets`, a `cooldown_ms` and a `once` flag. `RuleTrigger` is internally tagged on `kind`; an
unknown kind from a newer build fails the whole parse, and the loader then ignores the file. The
kinds, by the input that evaluates them:
| input | triggers |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| a snapshot | `Price` — the published mark crosses a level; `Drawdown` — the daemon's own equity curve draws down from its session peak, latched; `ReconAlert` — a held reconcile divergence is present, latched |
| an indicator sample | `Indicator` — a streaming-indicator output crosses a threshold; the rule stores the registry key, output index and params, and the consumer computes the value |
| a typed event | `Fill`, optionally scoped; `OrderRejected` — a venue rejection or a `RiskGate` denial |
| a status signal | `Feed` (degraded or recovered), `FillRateBreaker`, `PolymarketResolution`, `SeriesStale` |
Crossings are edge-triggered: `Above` fires on the transition from at-or-below to strictly above,
never every tick the value stays there. `Drawdown` deliberately tracks `Portfolio::drawdown_curve`
— configured capital plus the daemon's own P\&L — and not the cross-venue equity total, because the
total includes each live venue's wallet, which a third party can move; it is the quantity the core's
own liquidate-only latch uses, so the two cannot disagree. `SeriesStale` is the trigger the crate was
split for, and it is deliberately not `Feed`: feed health is venue-scoped and reports a transport
transition, while this one is series-scoped and reports an absence — the socket is established, the
subscription returned `Ok`, and there are no rows. Its scope is a prefix match on the recorder's
`kind/venue/symbol` key, because a Polymarket up/down family mints new token ids every five minutes
and the series an operator wants to watch does not exist yet when the rule is written.
## Evaluation [#evaluation]
`eval.rs` is the pure evaluator: `(rule + per-rule state + one input) -> Option`, with
no I/O, no clock (the caller passes `now_ms`) and no delivery, so every trigger's fire-or-not is
unit-tested with plain values. Each of the four entry points returns `None` for a rule whose trigger
belongs to a different input, so the engine hands every enabled rule to every entry point without
partitioning. `RuleState` holds the previous scalar sample — `None` until the first, so a rule can
never fire on its very first observation — the running drawdown peak, the latch, the last fire time
and the once flag; `maybe_fire` applies cooldown and once before building the alert. State is keyed
by rule id in an `IndexMap` so fire order is deterministic, and `set_rules` clears all of it: a rule
edit is a config change, and every latch re-arms.
## Delivery [#delivery]
An `AlertSink` is fire-and-forget: `deliver` must never block the caller for long and must never
panic, because the engine calls it inline while draining rules. Each sink self-filters on the
alert's `AlertTargets`, so routing lives in the alert rather than the dispatcher. `InProcessSink` is
an in-memory buffer for the toast surface a desktop app would drain on repaint, and no shipped
mount registers one. `WebhookSink` posts to Telegram or a generic JSON webhook over the workspace's
one `ureq` and rustls stack through an injected transport with a 10 s timeout, so the seam is
testable with a fake sender and never touches the network in CI. Webhook names in a rule resolve
against configs built from a **caller-supplied** credential map by `webhook_configs_from_env`; a
name with no config delivers nowhere, and the crate never opens the credential store itself.
`WebhookKind` holds the bot token or the webhook URL, so its `Debug` is manual and redacting and it
is never serialized beside the rules. A failed delivery logs the target name and a coarse error
category only — never the endpoint URL and never the raw transport error, whose display can echo
the request target. A bare HTTP status carries no secret and is surfaced; everything else is
"transport error".
## Persistence, and where `alerts.json` lives [#persistence-and-where-alertsjson-lives]
`persist.rs` reads no environment variable and resolves no path. `load_path` and `save_path` take a
caller-supplied `&Path`, and `ALERTS_FILE` — `alerts.json` — is the one thing the module states, so
the binary composing the directory does not re-spell the basename. An absent or unparseable file is
`None`, the off state, never an error. Every field but `id` and `trigger` is `#[serde(default)]`,
so an older file still loads, and `AlertRuleSet` carries a schema version, currently 1. The daemon
resolves `$VIKE_ALERTS`, falling back to `/settings/state/alerts.json` — the
[settings page](/docs/trader/reference/settings/alerts) carries the file's shape.
In the [headless daemon](/docs/trader/reference/binaries/vike-tradehub), `maybe_mount` is the gate: no
file, a file with zero rules, or a file whose every rule is disabled all return `None`, and then
nothing is constructed — no engine, no sink, and no webhook token loaded into memory, because the
webhook configs are passed as a thunk the off path never calls. When it mounts, a `LogSink` is
always registered (a `tracing` warn record on the log file, never stdout, which stays the daemon's
protocol), and the engine is driven from the existing summary thread, one `on_snapshot` per tick.
That is the one input the daemon can feed today, so `Price`, `Drawdown` and `ReconAlert` fire
there; event, indicator and signal rules load, are counted, and never fire in the daemon —
`SeriesStale`'s producer lives in `vike-recorder`'s own alerts module. The window that edits rules
in the desktop app is an explicit follow-up.
# Architecture (/docs/trader/concepts/architecture)
Vike Trader is a Cargo workspace of small crates. Three things about its shape are load-bearing
and worth knowing before any other concept page: dependencies point in one direction only and a
test enforces it; every crate declares its own rank; and the five binaries that boot a process do
so through one shared sequence rather than five copies of it.
## Dependency direction is down only, and it is a gate [#dependency-direction-is-down-only-and-it-is-a-gate]
Every workspace member declares its rank in its own manifest:
```toml
[package.metadata.vike]
layer = 30
```
`crates/vike-ops/tests/layer_gate.rs`'s `every_normal_vike_dependency_goes_strictly_down` asserts
that for every **normal** (non-dev) `vike-*` dependency edge `c -> d`, `layer(d) < layer(c)`. It is
text-only over the manifests — no cargo invocation — and it fails a pull request rather than
producing a review comment. A dev-dependency may point upward, and the workspace does so on
purpose: `vike-mm` dev-depends on `vike-core` so its white-box tests can build a concrete
`LiveBroker`, and `vike-bridge-core` dev-depends on ten venue crates to run the shared conformance
harnesses. `dev_dependencies_genuinely_point_upward` in the same file asserts that the exemption is
still needed, so nobody can fold dev edges into the rule and have it pass for the wrong reason.
`[build-dependencies]` are not exempt.
The gate exists because of one edge that actually shipped. A single normal dependency from
`vike-tradehub` — the headless daemon that runs on the box that signs real orders — to
`vike-app-core` dragged the entire egui widget stack into that daemon, for three pure modules. The
cost was never CI minutes: it was daemon build time, binary size, and the
`cargo deny --all-features` supply-chain audit surface on a production trading node. It was found
by somebody noticing build times — no test, no gate, no diff comment. `vike-ops` exists because of
it.
### The tiers [#the-tiers]
The ranks were established by reading the real graph — all 275 normal `vike-*` edges in the tree
the gate was written against — not from a narrative order, and the tree had no violation to fix
when the gate landed. `layer_gate.rs`'s `TIERS` is the authority, and a crate's `layer` must be one
of its ranks, so a new rank costs a row with a written meaning; the gaps are deliberate, so a crate
can be slotted between tiers without renumbering the workspace.
| rank | tier | what sits here |
| ---- | --------------- | --------------------------------------------------------------------------------- |
| 10 | vocabulary | `vike-model` itself — the shared domain vocabulary every other layer names |
| 15 | leaf | a pure leaf with no `vike-*` dependency of its own |
| 20 | domain | one hop over the vocabulary: a single-purpose pure core |
| 25 | boot | the composition-root startup sequence, below every binary that runs it |
| 30 | engine | a runtime, simulation or transport core over the domain crates |
| 35 | user-host | a `build.rs` host that compiles user-authored code out of `user_data` |
| 40 | venue | a per-venue bridge built on `vike-bridge-core` |
| 41 | venue-stacked | a venue bridge built on a sibling venue's family driver |
| 45 | research | an offline study crate that mounts no venue — vacant today, kept deliberately |
| 50 | service | a library composed over the engine crates |
| 55 | service-stacked | a service composed over another service crate |
| 60 | mount | the venue composition root (`make_engine`) |
| 62 | mount-stacked | a composition root over `vike-mount` |
| 65 | daemon | a headless top-of-graph binary |
| 68 | multicall | the multicall dispatcher: depends on every headless binary, nothing depends on it |
| 70 | ui-foundation | the egui foundation leaf — everything above here links egui |
| 75 | ui-widget | an egui widget or engine crate, no eframe/wgpu |
| 80 | ui-shell | an egui shell assembled from the widget crates |
| 90 | app | the desktop application: the outermost shell |
Where two families are genuinely incomparable the numbers still have to put one above the other,
and the gate then permits the wrong-direction edge in whichever direction was chosen. Those choices
are made toward the mistake that has actually happened. The whole UI stack is ranked **above**
every headless daemon — which is what makes the motivating `vike-tradehub -> vike-app-core` edge
fail; ranked by topological depth instead, it would pass. And venues (40) sit **below** the
simulator (50), so a bridge crate that reaches for `vike-backtest` is red; the reverse edge is the
one the numbers let through, because pulling the engine into a venue adapter is the "dragged the
whole tree in" shape the gate exists for.
## The layering rule that keeps being re-learned [#the-layering-rule-that-keeps-being-re-learned]
When two sides must not disagree, the cure is a shared crate **below** both, not a shared crate
containing both. `vike-fills` (`crates/vike-fills/src/lib.rs`) is the worked example: one
definition of what a fill costs, when it happens and at what price, extracted verbatim out of the
backtest engine so that the paper exchange and the simulator share a *dependency* rather than a
crate. While both lived inside `vike-backtest` their agreement was true by co-location, and the
price was that every consumer wanting only the paper executor compiled the whole simulator to get
it. That is what lets `backtest == paper` be gated bit-for-bit rather than asserted — the
[paper exchange](/docs/trader/concepts/paper-exchange) page carries the law and its scope.
## One startup sequence, one owner [#one-startup-sequence-one-owner]
Five composition roots — `vike-desktop`, `vike-tradehub`, `vike-recorder`, `vike-cli` and
`vike-datahub` — used to run the same ordered steps from five copies: refuse a removed environment
variable, resolve `/settings`, load the credentials (and refuse a credential file that
arms real money), load the settings, derive the project-relative paths and build the log
subscriber, then disclose what was resolved. `vike_boot::boot` (`crates/vike-boot/src/lib.rs`)
owns that order now, and `crates/vike-boot/tests/one_owner.rs` is the gate that a root may not
re-spell it: a call to a step of the sequence must be in `vike-boot` itself or in a declared
exception row carrying its reason, and a crate that boots may not call a project resolver anywhere
under its own `src/`. The exceptions today are re-reads rather than second startups — the CLI's
`config check` pre-flight, which re-runs a refusal in order to *report* it, and the daemon's
runtime hot-apply and the Data Manager's arming screen, which both re-read settings out of the
directory the boot already resolved and handed them.
Each root declares how it departs from the sequence with a `BootSpec` whose arms carry a reason —
`RemovedEnv::Ignore`, `SettingsLoad::Skip`, `Credentials::Deferred`, `LogHome::Elsewhere`,
`Disclosure::Skip` — never a `bool`. Turning a startup refusal off is exactly the change that
should have to justify itself in a diff. `vike-cli` runs the refusal for every subcommand but
builds no subscriber and defers the credential read; `vike-datahub` loads no settings at all.
The load-bearing property is not the order, which every root got right by copying. It is that
**one project walk decides per process**, and every project-relative path is derived from its
answer: the settings load, the state root (`Booted::state_dir`), the log home and the startup
disclosure. Before that, four sites reached a resolver that did not honour `$VIKE_SETTINGS_DIR` —
so the second answer was specifically the one that ignored the override, on exactly the
deployments that set it: a desktop whose rolling log file and startup block named two different
projects.
Two resolutions remain outside the boot, and neither can disagree with it. The credential store is
reached through the root's own loader (`Credentials::LoadWith`) over the same pure resolver and
the same override, so it is a duplicated cost, never a second answer. And the halt sentinel's
default location is memoized deep inside a library with no boot in reach, so the two booting
trading roots hand it `Booted::state_dir` as a parameter — with one declared residual: two mount
binaries in the tree never call `boot` at all, so their sentinel still walks, and nothing gates it
either way.
It runs the **arming refusal before the settings load** — a tree that arms real money and has a
broken `policy.toml` must fail with the arming message, the more urgent of the two. It **logs
nothing and prints nothing**, because it runs before `vike_log::init` and the log directory and
both log levels are themselves settings; everything it would say comes back as data and the root
emits it once a subscriber exists. And it **never opens the credential store** itself — the loader
is a function the root supplies, so `vike-cli` can defer the read entirely and no consumer
inherits the bridge crates' HTTP and TLS stack; measured on the tree the crate was written against,
the one `vike-log` edge it refuses would add 43 packages to `vike-cli`.
A layering failure is read on the [structure gates](/docs/trader/developer/structure-gates) page; the
rule itself lives here.
# Contingent orders (/docs/trader/concepts/contingency)
A bracket is three orders with two relationships between them: the protective stop and the
take-profit are **held** until the entry fills (one-triggers-other), and once armed, whichever exit
fills **cancels** the other (one-cancels-other). `crates/vike-exec/src/contingency.rs` is the one
place those two relationships are resolved, and its header says why it had to be one place.
## The defect that made it one resolver [#the-defect-that-made-it-one-resolver]
Before this module the law lived lane-dependently. The paper book ran full one-cancels-other on
every fill, while the backtest `SimBroker` cancelled a fired protective stop's exit siblings only on
its granular sub-bar lane — its coarse and tick lanes left the take-profit limit resting on a flat
book, so one wide bar could fire the stop *and* the take-profit and open a spurious reversed
position. The paper book's own `apply_contingency` was the semantic template the module lifts.
The module owns only the **decision** — which children arm, which siblings cancel. Both callers
keep their own book mechanics: resting-order storage, event emission, the actual cancels. That
split is what makes the two lanes structurally unable to disagree again, and the
[paper exchange](/docs/trader/concepts/paper-exchange)'s r7 gate has a bracket row that proves it — the
backtest broker's entry-with-protective-stop plus resting take-profit fills bit-identically to the
live `submit_bracket`'s triple resting in the paper book, with both stops firing through the one
trigger oracle and both sides cancelling the surviving exit.
## Two linkage shapes, one law [#two-linkage-shapes-one-law]
**Coid-linked** — `ContingencyBook`. Explicit `parent` and `linked` client-order-ids ride on the
order request, which is what the bracket lowering `vike_model::build_bracket` emits: an entry with
no parent, linked to both exits; a stop whose parent is the entry, linked to the take-profit; a
take-profit whose parent is the entry, linked to the stop. Live, `OrderIntent::Bracket` is the verb
— the runtime mints the three ids and wires the links — and the paper book keys its own book by
those ids.
**Kind-linked** — `is_protective_exit_sibling`. The backtest `SimBroker` arms an id-less implicit
bracket through `WorkingOrder::stop`, so after a protective-stop fill flattens the position there is
no id to follow. Its siblings are identified *by kind*: a resting order is that stop's
one-cancels-other partner, and must cancel, if and only if it is a closing-side `Limit` or
`LimitClose` exit. Entries and adds on the opposite side never match, and neither do stop or
trailing kinds — a second protective layer is not a take-profit. The test pins both directions: a
long stopped out cancels sell limits and keeps a re-entry buy limit and a second stop; a short
stopped out mirrors it.
## What the book does on a fill [#what-the-book-does-on-a-fill]
`ContingencyBook::insert` records a leg's linkage. A held exit — one with a parent — starts
*inactive*; an entry starts active immediately. `is_held` is the question a fill pass asks before
letting an order fill: a held order must not, and every plain order, which is never inserted at
all, is never held. That absence is the byte-identical path for non-bracket runs — a book with no
brackets stays empty and every resolver call is a cheap no-op.
`on_fill` resolves one fill in two steps: arm every held child of the filled id, then return its
one-cancels-other siblings — its `linked` ids that are **not** its own children — in `linked` order.
The caller cancels whichever of those still rest in its book and calls `remove` for each one it
actually cancelled; a sibling already gone cancels nothing. The bracket test shows the shape: the
entry's fill arms both exits and cancels nothing, because the entry's linked ids are its own
children; the stop's fill then returns exactly the take-profit. A plain, unrecorded fill arms nothing
and returns empty.
`siblings_of` answers the same sibling question read-only, so a *dying* protective exit — cancelled
or expired rather than filled — resolves its surviving partner off the same linkage a fill would,
and the terminal-cancel and fill-cancel paths cannot disagree about who the sibling is.
`children_of` is the walk step of the expire/cancel cascade: a child whose parent terminated
without filling can never arm. `clear` is the mass-cancel twin.
`insert` derives `active` from "has no parent", which is wrong for a child that was already armed by
its parent's fill before a crash: re-inserting it would silently re-hold an armed exit. So the book
exposes `snapshot` — every entry as `(coid, parent, linked, active)` in insertion order — for the
journal's `Snap` record, and `insert_active` takes the flag explicitly for the restart re-seed.
Neither is used on the live submit path; both exist so [replay](/docs/trader/concepts/journal/replay)
reproduces the same arm and cancel iteration order the live book had, which is also why the book is
an `IndexMap`: the arm sweep and `children_of` iterate the map, and insertion order keeps the event
stream reproducible.
On the venue ingest lane the runtime drives this resolver only when the
[event bus](/docs/trader/concepts/event-bus) reports that the engine actually applied the fill. The
`Fold` verdict was added for exactly this consumer: a fabricated `OrderFilled` on an unknown
client-order-id used to cancel its sibling and arm its held children even though the engine had
dropped the event — a naked position on the strength of a fill that never happened.
`crates/vike-core/src/runtime/mod.rs`'s `pump_client` records that the in-process client seam is
deliberately not gated the same way.
One boundary of the coid-linked shape is declared where it bites. The paper client's combo book
does not support contingency links *on* a multi-leg combo — a combo request carrying a parent or
linked ids is terminally rejected, because the hold and cascade machinery enforce against the
single-symbol resting book only — while a plain order may link a resting combo as a sibling, and a
combo's fill arms plain held children as any fill does. What each order kind means, and which venues
carry it natively, is under [orders](/docs/trader/concepts/orders/); a conditional the core emulates for
a venue without one is [emulated orders](/docs/trader/concepts/emulated-orders).
# The control boundary (/docs/trader/concepts/control-boundary)
`crates/vike-core/src/control.rs` names the external control boundary of the live core in one
place, and its contract fits in a sentence: `Command` and `OrderIntent` go **in** through
`CoreHandle::try_command` (non-blocking, the desktop app's path) or `CoreHandle::send_command`
(lossless, for session control and tests), and `CoreSnapshot` queries come **out** through the
snapshot's accessors — `orders_for`, `order`, `open_order`, `position`, `last_mark`, `equity`,
`trading_state`. The desktop app, the CLI and the MCP server are translators over that seam. Nothing
bypasses it.
## Why one seam matters: the risk gate is structural [#why-one-seam-matters-the-risk-gate-is-structural]
Every order-scoped verb is an `OrderIntent`, and every intent from every origin lowers through the
core's single `apply_intent` site: mint a client-order-id, route, run the `RiskGate`, hand the order
to the venue client. `crates/vike-core/src/runtime/apply.rs` lists the origins it serves — the
external command path, a mounted strategy's drain, a conditional order firing, a margin-call
liquidation — and notes that a source-scan test in the control-boundary suite enforces that no engine
submit call lives outside that file. The consequence for a reader is that there is no path by which
a front end can place an order that the [risk gate](/docs/trader/concepts/risk-and-arming) did not see. It
is a property of the code's shape, not a convention each caller has to remember.
## Fire-and-forget [#fire-and-forget]
Commands have no synchronous reply. The core is a [single-writer fold](/docs/trader/concepts/execution-core),
and a synchronous order handle was considered and rejected. A caller that must know its
client-order-id pre-mints it: a non-empty `client_order_id` on the request is respected, and an
empty one asks the core to mint. Outcomes are observed through the snapshot — `rejected_commands`
counts queue rejections, and a `RiskGate` veto surfaces as an `OrderDenied` event in
`recent_events`. Everything about an order's later life arrives as venue events and is read from the
[published snapshot](/docs/trader/concepts/portfolio-and-snapshot), which is how the
[order lifecycle](/docs/trader/concepts/order-lifecycle) is meant to be watched.
The two entry points differ only in what happens when the ingest lane is full.
`CoreHandle::try_command` is a `try_send`: it never blocks, a full queue returns
`CommandRejected::Busy` and increments the same rejected counter the snapshot surfaces, and a closed
lane — the core has exited — returns `CommandRejected::Gone`. `CoreHandle::send_command` is a
`blocking_send` into the same lane: every command queued ahead of it still folds first, and a closed
lane is ignored, matching `shutdown_and_join`.
`crates/vike-core/src/runtime/mod.rs` pins the ingest design: one dedicated thread owns all mutable
trading state and reaches it through one totally-ordered queue. Exec events from venues are lossless
and back-pressure the venue task — never the GUI. Commands from the GUI use `try_send` so the GUI
thread never blocks, and a full-queue rejection is returned to the caller **and** counted in the
snapshot, so it cannot be silent. Market data rides a latest-wins conflation slot with at most one
marker in flight. The two command paths are the same lane with two policies for the full case.
## The out-of-process write half [#the-out-of-process-write-half]
`CoreHandle::command_sink` hands out a `CommandSink`: a narrow, cloneable capability holding only
the ingest lane and the shared rejected counter. It is what the headless daemon's control server
holds, and it lowers a remote `Scope::Control` peer's order command into the same `apply_intent`
seam — carrying no snapshot read, no market slot and no join handle, so a connection can neither
read state nor shut the core down. Its `try_command` is, by its own doc, the exact body of
`CoreHandle::try_command`, so mint → `RiskGate` → client stays structural on the network path too.
The sink also carries `send_blocking`, the lossless twin, for the operator's own stdio loop — a human
at a terminal may wait for their typed order. The remote server path deliberately keeps
`try_command`: a network peer must never be able to back-pressure a per-connection thread. How a
remote peer reaches the sink at all — keys, framing, the handshake — is the
[node protocol](/docs/trader/concepts/node-protocol).
## Building another front end [#building-another-front-end]
The module's own guidance for a new adapter is short. In-process — a headless mode or a REPL inside
the running core binary — holds the `CoreHandle` and the snapshot cell directly; no transport is
needed. `Command` is already `serde`, so the recommended first cut for anything else is newline-JSON
over stdio. Out-of-process additionally needs a transport and `Serialize` on the snapshot view
types; that layer was parked until a real consumer existed, and the write half of it now does, as
the `CommandSink` above. The [CLI's trade verb](/docs/trader/reference/cli/trade/trade) and the
[agent tools](/docs/ai/trader/) are both translators in exactly this sense: they compose commands, submit
them, and read the snapshot.
# The data model (/docs/trader/concepts/data-model)
`vike-model` is rank 10 in the [layering](/docs/trader/concepts/architecture): the vocabulary every
other crate names and depends on. Almost all of it is pure value types; the exceptions touch disk —
`crates/vike-model/src/state_path.rs`, `store_path.rs` and `tick_store_path.rs` probe the filesystem
to answer where a project, its settings and its store root are, and `change_journal.rs` and
`scratch.rs` write under the answer. The crate header carries the parity rules every port in it
obeys — `f64` end to end, expressions in the same order as the source they were ported from, no
fused multiply-add, exact-zero flat checks kept as `== 0.0`, timestamps as epoch-millisecond `i64`.
This page is the handful of its types a reader meets on every other page.
## Bars and ticks [#bars-and-ticks]
`crates/vike-marketdata/src/bar.rs`'s `Bar` is one OHLCV candle, resolution-agnostic: a timestamped
price event with `ts`, `open`/`high`/`low`/`close`, `volume`, and four optionals — `funding` (a
perpetual's rate at this bar, if available), `bid` and `ask` (the opening best quote of the bar's
window, tick-derived and `None` for plain OHLCV bars), and `symbol`, the fully-qualified
`SYMBOL.VENUE` id the engine dispatch attaches. `QuoteTick` and `TradeTick` are the two tick
shapes, and both carry `local_ts` beside `ts`: the machine receive time, captured so feed latency
can later be *replayed* rather than modelled — it cannot be captured retroactively, and `0` means
the row was never stamped (a fixture, a backfill).
Ticks become bars in `crates/vike-marketdata/src/consolidator.rs`: a quote bar takes OHLC from the mid,
`volume` as the tick count, and the bucket's *opening* best bid and ask — what a next-open market
order fills against — while a trade bar takes OHLC from price, `volume` as summed size, and no
quote. Two shapes live there: the batch `consolidate_*` functions, hex-pinned against frozen
fixture bytes by the [parity gate](/docs/trader/concepts/parity-gate), and `BarConsolidator`, the
streaming twin for live folds where ticks arrive one at a time.
## The L2 book, and its one delta-apply law [#the-l2-book-and-its-one-delta-apply-law]
`crates/vike-marketdata/src/orderbook.rs`'s `L2Book` is the in-core reducer: venue tasks decode depth
deltas, the book folds them, and a strategy reads the result through `on_order_book`. Prices are
quantized to integer ticks — `price / tick`, rounded half to even — so levels live in a
`BTreeMap` keyed by tick: exact equality with no float-key hazard, ordered for O(log n)
best bid and ask. A delta whose quantity is zero removes the level, by venue convention. All its
arithmetic is add, subtract, multiply, divide and compare, so it is bit-parity eligible.
Whether an incoming delta folds, is dropped as stale, or means frames were lost is **one**
decision, `L2Book::delta_decision`, with two policies, so that live consumers and replay can never
diverge:
| `SeqPolicy` | applies when | stale when | gap when |
| ----------- | --------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `Monotonic` | `seq > last_seq` | `seq <= last_seq` (except the "no seq supplied" sentinel `0`, which always applies) | never — a jump is normal on these streams, and gap detection is the bridge's own upstream rule |
| `Strict` | `seq == last_seq + 1` | `seq == last_seq` | anything else: a forward jump (dropped frames) or a regression (a venue restart) — the consumer must resync from a fresh snapshot |
`Strict` is the rule for streams whose sequence increments by exactly one per applied event, and
for the recorded `BookUpdate` chain every feed emits — which is what gives
[tick replay](/docs/trader/backtesting/tick-replay) its integrity rule.
`avg_px_for_quantity`, `quantity_for_price`, `simulate_fill`, `can_fill` and `fill_ratio` are pure,
read-only estimates against **displayed** liquidity. Hidden flow, latency and queue dynamics only
make the real fill worse. Their intended consumers are the risk gate's opt-in impact veto and the
DOM ladder's cost-to-fill display, and degenerate inputs — a NaN, a crossed book — degrade rather
than panic.
## SymbolProperties — the per-symbol placement grid [#symbolproperties--the-per-symbol-placement-grid]
`crates/vike-model/src/instrument.rs`'s `SymbolProperties` holds the venue-neutral order-placement
bounds every venue's REST parser fills from its own instrument endpoint: `tick_size`, `step_size`,
`min_qty`, `max_qty`, `min_notional`, and the later arrivals `contract_size` (how much underlying
one unit of quantity controls — a Deribit BTC option is one BTC per contract) and an optional
tiered `tick_scheme`. It lives in `vike-model` rather than in the bridge crates because it is pure
domain data consumed on the venue-neutral side of the seam; the per-venue parse functions stay
with their venues. An absent property is `0.0` — "the venue did not constrain this" — and the
[risk gate](/docs/trader/concepts/risk-and-arming)'s limits are built from this grid at mount. The
struct grows, and its doc says to construct it with functional-update syntax for that reason: one
added field once broke construction across fourteen crates. Its own page in the
[instruments](/docs/trader/concepts/instruments/symbol-properties) tree goes deeper.
## The fill primitive [#the-fill-primitive]
`crates/vike-model/src/fill.rs`'s `compute_fill` is the **one** cost-basis primitive: how a fill
transitions a position — open, add, reduce, close, or flip — returning a `FillOutcome` with the new
size and average price, the closing quantity, the realized PnL on it and the leftover opened on the
far side of a flip. Callers keep their own cash, fee and trade-record bookkeeping and delegate only
this shared arithmetic, which is what stops the backtest engines and the live read-model drifting
from each other. Its branch math mirrors a frozen fixture bit for bit, and its own documentation
says not to reorder an operation, introduce a fused multiply-add, or "simplify" an expression.
## The Event enum [#the-event-enum]
`crates/vike-model/src/events.rs` defines the immutable order, fill, position and account events.
Every one is a plain value struct — `Clone`, no interior references — so events are copy-safe
across the channel boundary between a venue thread and the core. `Event` is the tagged union the
core loop consumes, serialized with a `"type"` tag, and it has twenty variants:
`AccountState` · `Fill` · `Funding` · `OrderAccepted` · `OrderCancelRejected` · `OrderCanceled` ·
`OrderDenied` · `OrderExpired` · `OrderFilled` · `OrderLiquidated` · `OrderModified` ·
`OrderModifyRejected` · `OrderPartiallyFilled` · `OrderRejected` · `OrderSubmitted` ·
`OrderTriggered` · `PositionChanged` · `PositionClosed` · `PositionLiquidated` · `PositionOpened`
Each has its own page under [events](/docs/trader/concepts/events/), generated from the enum so the
list cannot drift. The [order lifecycle](/docs/trader/concepts/order-lifecycle) page is where the order
events meet the state machine; `OrderRequest`, in the same crate, is the submission intent
itself — what a strategy, a manual ticket or a bracket builder constructs, with every field's Rust
`Default` pinned equal to its `#[serde(default)]`.
## The interning rule [#the-interning-rule]
`venue` and `symbol` on every event are interned strings — 8-byte `Copy`, pointer-equality,
serde-transparent. The interner **never frees** an entry, so interning is only sound for a field
whose set of distinct values over a process lifetime is bounded. That holds for a venue (about ten
values ever) and a symbol (bounded by the distinct instruments subscribed per session — even a
prediction-market token id is minted per market and reused on every tick of it, so the interned
set is hundreds to low thousands of markets, not the trade count).
It is minted **per fill**, unbounded, so an interned trade id would leak without bound. It is a
`TradeId` newtype over a compact stack string instead. The newtype also closes a measured defect:
`trade_id` is the dedup key for the whole money lane, and an empty id did not dedup badly — it
skipped dedup entirely, so a re-applied fill double-booked commission and realized PnL on a live
daemon. `TradeId` implements no `Default`, so the `unwrap_or_default()` that produced those
empties is a compile error; `TradeId::new` refuses `""` and hands the venue mapper the decision.
The rule generalises: only bounded, repeated label fields are interned; high-cardinality per-event
ids are not; and secrets are never interned, because an interned string cannot be zeroized.
# The dead-man's switches (/docs/trader/concepts/dead-mans-switch)
A dead-man's switch is the automatic counterpart to the manual [halt](/docs/trader/concepts/halt)
sentinel: when nobody is in front of the daemon, something cancels the resting orders anyway. Vike
Trader ships **two of them**, and one sentence separates them — *what each one accepts as evidence
that the daemon has lost the market*:
* **The connection dead-man** reads the link state a venue's own bridge discloses. A feed reported
`Disconnected` for longer than `policy.toml`'s `link_deadman_grace_ms` cancels **that venue's**
resting orders. **The key absent means ARMED**, at two minutes.
* **The silence dead-man** reads ingest. No venue event, tick, bar, quote, trade or book update on
*any* venue for longer than `deadman_timeout_ms` cancels **every** resting order. **The key
absent means OFF**, with one warning at mount.
The defaults point in opposite directions because a market that **closes** is silence and is not a
disconnect. The connection switch cannot see a close, so it may be armed by default; the silence
switch cannot tell one from an outage, so it may not.
| | connection dead-man | silence dead-man |
| -------------------- | ------------------------------------------------------- | ----------------------------------------- |
| key in `policy.toml` | `link_deadman_grace_ms` | `deadman_timeout_ms` |
| absent | armed at `DEFAULT_LINK_DEADMAN_GRACE_MS` — 120 000 ms | off, warned about once |
| off is spelled | `= 0`, the only off | `= 0`, or left out |
| evidence | `FeedStatus::Disconnected` per (venue, symbol) | nothing arrived, anywhere |
| a session close | invisible — a close reads `Stale`, which never counts | trips it |
| a trip cancels | the venue whose link died | every venue |
| where it arms | a per-venue table *and* a disclosing lane in this mount | wherever the key is written |
| authority | `crates/vike-core/src/runtime/link_deadman.rs` | `crates/vike-core/src/runtime/deadman.rs` |
They are independent: either, both or neither may be on. They are separate `CoreConfig` fields, and
they share exactly one setting — `deadman_action`, because *what a trip does* is the same question
for both and a second key would be a second answer to it. Both are folded from `policy.toml` by
`crates/vike-tradehub/src/tradehub_cli.rs`'s `live_mount_with` and by nothing else: the daemon's own
paper rehearsal arm constructs neither, and neither does `vike-desktop` or
`crates/vike-run/src/bin/ibkr_mount.rs`.
## The connection dead-man [#the-connection-dead-man]
The signal is the per-(venue, symbol) `FeedStatus` the bridges disclose. It reaches the core through
two seams that the latch cannot tell apart: a feed that owns a `LiveDataSink` maps the data layer's
`StreamStatus` 1:1 by `crates/vike-core/src/core_sink.rs`'s `stream_status`, and a feed that owns no
sink — the HFT tick pumps, which take a bare `vike_exec::TickSender` — pushes a `FeedStatus` onto
the tick lane directly, through the one map in
`crates/vike-bridge-core/src/stream_health.rs`'s `HealthEvent::feed_status`. Both land on the same
`Ingest::StreamStatus` arm. `Disconnected` opens a grace window for that key; `Live` for the same
key closes it and re-arms the link; and `Stale` **never counts, in either direction**. That last line is the entire difference between this
switch and the one below. `Stale` means "no fresh price exists", which is an accurate disclosure of
a weekend on an FX venue and of a scheduled venue maintenance window — reacting to it would be the
silence switch wearing a new name.
A `Disconnected` opens the window whether or not that link was ever seen `Live`, and that is a
correction rather than a simplification: no bridge on this roster discloses a `Live` on a first
successful connect — `vike_bridge_core::stream_health`'s `StreamHealth::recover` returns nothing
when no gap is open, so `Live` exists only to *close* one. A gate on a prior `Live` would have made
the switch blind to the first socket death of a mount, which is the outage it exists for. The
startup dial-up is absorbed by the grace instead.
Recovery is disclosed on the **first frame back**, never at connect, and that difference is what
keeps a flapping venue disclosed down. A successful dial proves only that the venue accepted a
TCP+TLS handshake; an inbound frame — data, or a server ping the socket answers — proves the wire
carries traffic again. On a connect-time `Live`, a venue that accepts a connection and drops it in a
loop (a rate-limit ban, a half-dead edge) would emit `Live`/`Gap` on every cycle and reset the grace
window each time, so the switch could never trip on the outage it is most needed for. One
`Disconnected` per outage, for the mirror-image reason: each feed owns **one** `StreamHealth`
(`crates/vike-bridge-core/src/stream_health.rs`) across its whole lifetime, re-dials included, so a
venue that flaps for ten minutes discloses one disconnect and one recovery rather than three hundred.
`crates/vike-core/src/runtime/link_deadman.rs` is a clock-free latch — two fields per link, both
entry points taking an injected `now_ms` — evaluated at the drain-loop boundary on the
[timer wheel](/docs/trader/concepts/timers), on its own `LinkDeadManSweep` cadence of half the grace. An
entry exists only for a link that has actually been disclosed down at least once, and a status
change on an unarmed venue costs one set lookup and no allocation. The per-message fold has no
branch for the feature at all.
`crates/vike-config/src/policy.rs` accepts absent, `0`, or `MIN_LINK_DEADMAN_GRACE_MS` (30 000 ms)
through `MAX_LINK_DEADMAN_GRACE_MS` (3 600 000 ms). The floor is the measured worst *ordinary*
reconnect across the lanes that report a dead link, which is the L2 depth driver: a bounded 10 s
dial, a 10 s REST book seed and a 3 s backoff, 23 s end to end. It is the slowest of the three — the
tick pumps a live `vike-tradehub` mount actually subscribes come in at 21 s and under — so anything
under the floor makes the switch
trip on the re-dial that follows an ordinary socket fault, which is the false-trip class the design
exists to remove. Above the ceiling it is a switch that never fires. A value outside the range is
rejected at load with the key and the file named, because a grace that was silently raised is a
grace the operator believes they set and do not have.
## Which venues actually arm [#which-venues-actually-arm]
Arming needs **two** independent facts to be true, and the second one is a fact about your daemon
rather than about the venue.
**One: the venue's row.** `crates/vike-model/src/link_deadman.rs` carries one named row per roster
venue, each read from the emitter it cites, with three dispositions. `Armed` — the venue trades
continuously *and* its bridge discloses a disconnect: **binance, aster, bybit, okx** — each from
two emitters, both on a book socket: the DOM depth feed and the HFT quote/trade/book tick pump —
and **polymarket** (from its primary book feed). `SessionBounded` — the
bridge does disclose one, but the market has sessions and a close has not been observed to read as
`Stale` rather than as a gap: **oanda** and **ig**, off until somebody watches a weekend through.
`Inert` — the bridge hands a gap to the sink nowhere at all, so the switch could not fire even if it
were armed: **hyperliquid, deribit, alpaca, ibkr, ctrader, fxcm, dukascopy**. Each unarmed row
carries its reason verbatim, and the daemon prints it.
**Two: whether this mount subscribes a lane that carries the disclosure.** A row is a statement
about the *adapter*, never a promise about a *mount*. `crates/vike-tradehub/src/tradehub_cli.rs`'s
`mount_link_disclosure` is the reading, one arm per venue plan, and for a CEX venue the answer is
`Discloses`. That it is an answer at all is recent, and the history is the point: the daemon's
`VenuePlan::Cex` arm subscribes the kline lane and the quote/trade/book tick pump and deliberately
never `subscribe_depth` (`crates/vike-tradehub/src/feeds.rs`), and until 2026-09-06 neither of those
two lanes disclosed anything — the kline lane rides `vike_bridge_core::market_pump`, which carries
no link state at all, and the tick pump owns no `LiveDataSink`. The pump now discloses **its own
transport state onto the core tick lane it already writes ticks to**: `disclose_link` in
`crates/bridges/binance/src/family/depth.rs`'s `md_main`, and its twins in
`crates/bridges/bybit/src/market_data.rs` and `crates/bridges/okx/src/market_data.rs`.
Subscribing the DOM feed instead was the other way to close it, and it was rejected: that pump
already carries the venue's `@depth`-class book stream on the same host, so `subscribe_depth` would
open a second socket to the same stream, keep a second copy of the same book and add a periodic REST
re-seed — for a signal the first socket already has, delivered into `l2_snapshot`, which is a no-op
in every sink this daemon owns. The disclosure costs zero extra bytes on the wire this way, and the
core's per-message fold is unchanged: nothing new rides the tick, quote, trade, bar or book path,
only the occasional control event that was already there.
Fold the two together. **binance, bybit, okx and aster arm on a default live mount**: the tick pump
is the lane that mount subscribes, and it is the venue's book socket rather than a side channel.
**Polymarket is a fifth, behind its Cargo feature** — its `subscribe_book` on every seated token
feeds a `MakerSink` that forwards `stream_status`. Every session-bounded venue — the FX and equities
mounts — stays off deliberately, because a market closing is not an outage: until a weekend has been
watched through and seen to disclose `Stale` rather than a gap, the conservative row holds. Every
`Inert` venue stays off because nothing in its bridge would reach the latch.
**No row flipped direction.** This was a change of *reach*, not of classification: those four were
always `Armed` in the venue table, and the mount simply could not hear them — so
`link_deadman_config_from_policy` returned `None` and built no latch at all on the binary that signs
real orders, whatever `policy.toml` said. A daemon mounting only session-bounded or `Inert` venues
is still that case, and it is the ordinary state of an FX-only daemon: no latch, no timer, no
cadence contributed, and no automatic stop for a dead link. Nothing in `policy.toml` changes which
venues arm — that is a property of the bridge and of the lanes this mount subscribes.
The mount says which it is rather than leaving it to be discovered: `link_deadman_arming_report`
emits **one
line per mounted venue** at startup, and it keeps apart the four ways a venue ends up unarmed — its
market has sessions, its bridge discloses nothing, *this daemon subscribes no lane that carries it*,
or the operator wrote `0`. Read those lines; they describe your daemon and this page does not. The
mount-lane case is checked before the armed line is printed, which is the whole of the fix: an
earlier version logged `LINK DEAD-MAN ARMED for binance` on a daemon where nothing could ever reach
the latch.
## What an operator does about it [#what-an-operator-does-about-it]
There is no line that arms the connection switch, because leaving the key out arms it. The lines
that exist are the ones that change it:
```toml
# /settings/policy.toml
link_deadman_grace_ms = 45000 # a tighter grace; legal range 30000..=3600000
link_deadman_grace_ms = 0 # OFF, for every venue — the only off
deadman_action = "cancel_all" # a trip pulls the book and leaves the trading state alone
```
Neither key can be reached from the environment or the command line:
[`Policy`](/docs/trader/reference/settings/policy) implements neither `EnvOverride` nor `CliOverride` and
both traits are sealed, so an override is a compile error rather than a review comment. A stale
service-unit line cannot disarm a kill switch with no diff to review.
What this file cannot do is arm a venue. The row is a property of the bridge and of the mount, not a
preference: a venue whose feed hands the core no disconnect has nothing to observe, and a venue
whose only emitter is a lane this daemon does not subscribe has nothing to observe *here*. Both are
code changes, and the startup lines name which of the two you are looking at.
## The silence dead-man, and the morning it was default-on [#the-silence-dead-man-and-the-morning-it-was-default-on]
The older switch is `crates/vike-core/src/runtime/deadman.rs`: a four-field latch, deliberately
clock-free, whose `observe` records a monotonic maximum over every liveness message — venue events
and market data, never control or waker messages — so an out-of-order older frame cannot drag
freshness backward. `check` returns the action exactly once when the newest observed timestamp has
aged *strictly* past the threshold (`now − newest > timeout`; a lag equal to the timeout is still
fresh) and the switch has not already tripped this outage; fresh data clears the latch and re-arms
it. A core that has seen no data never trips, so pre-feed startup is silent rather than a nuisance.
The key shipped with a compiled-in 60 s default — armed unless an operator wrote `0` — and the
ruling was reversed the same day, once review established what the switch actually observes. It
counts ingest and deliberately nothing else: not a control command, not the periodic waker, and not
the one message that says a socket is alive. A transport heartbeat never reaches the core at all. So
it cannot tell a dead socket from a quiet market or a closed one, and an armed default bought three
halts nobody asked for. **The session-close halt**, deterministic: on FX over the weekend and on
equities every evening it trips about a minute after the last tick, at every close — cancelling a
GTC deliberately left over the weekend, halting every engine, writing the sentinel, and opening the
next session halted until an operator deletes the file. **The thin-market halt**, probabilistic: a
live mount following one illiquid instrument goes sixty seconds without a tick in a quiet minute.
And **the cap that could not be raised past the close**: the maximum timeout is one day and an FX
weekend is about 48 hours, so the only legal settings on an FX venue were `0` or a switch that trips
every Friday. `crates/vike-config/src/policy.rs`'s `Policy::deadman_timeout_ms` carries the record.
The safety argument was right; the mechanism was the wrong one for it, and the connection switch
above is the one it wanted.
So this key is now the **optional extra for a 24/7 mount** — a crypto perp venue that never closes
and always ticks — that also wants a silence detector. Absent is off; and because the daemon cannot
tell "the operator decided against it" from "the operator never heard of it", an absent key warns
once at mount. `deadman_timeout_ms = 0` is the only spelling that records the decision and silences
that warning:
```toml
# /settings/policy.toml
deadman_timeout_ms = 60000 # arm it — the recommendation for a venue that never closes
deadman_timeout_ms = 0 # a deliberate no, and the only thing that silences the warning
```
Nothing arms it silently: 60 000 ms is `RECOMMENDED_DEADMAN_TIMEOUT_MS`, the number the warning
suggests, not one any binary applies. Accepted values are absent, `0`, or 1 000 ms through one day,
rejected by name rather than clamped for the same reason as the sibling's bounds. Raise it for a
thin symbol; leave it off on anything that closes.
## What a trip does [#what-a-trip-does]
Both switches act at the drain-loop boundary on the [timer wheel](/docs/trader/concepts/timers), never on
the per-message fold, each on its own cadence of half its window — so a deadline is evaluated at
least twice before it expires. `crates/vike-core/src/runtime/watchdog.rs` holds both acting halves,
`sweep_deadman` and `sweep_link_deadman`. On trip each one:
1. emits one `tracing::warn!` per outage or dead link — a fault transition, within the logging
budget;
2. cancels through the one order-write vocabulary, `apply_intent` with `OrderIntent::MassCancel`, so
nothing bypasses mint, route or gate — and that verb also clears every armed
[conditional book](/docs/trader/concepts/emulated-orders), because a dead-man pulls all exposure. The
cancel is tagged `CancelIntent::RiskOff`: a cancel issued because the market was lost must not be
shed by a venue's own cancel budget;
3. under `DeadManAction::CancelAllAndHalt` — the default `deadman_action` — halts every engine
in-process and then writes the sentinel best-effort, a failed write logged rather than panicking
the core, since the in-process gate has already stopped new orders;
4. pushes a line into the recent-events ring and marks the snapshot dirty.
The **scope** is where they differ. The silence switch cancels with `venue: None, symbol: None` —
everything. The connection switch scopes the cancel to the venue whose link died, across **every
engine of that exchange**, so a two-account binance mount has both books pulled and a bybit socket
death leaves the binance book resting. One cancel pass per venue rather than per link: two symbols
of one venue expiring in the same sweep are one exchange fact.
HALT is the exception to that scoping and is deliberately process-wide, because the sentinel is one
file by construction and a half-halted daemon is a state nobody asked for. It is the *same* file a
manual `touch HALT` writes and every venue's submit boundary checks — the path is supplied by the
binary through `vike_bridge_core::halt::halt_path_from_env`, because environment reads stay in
binaries and `vike-core` takes no bridge dependency — so the automatic trip and an operator's hand
reach one file. A recovered link re-arms its own latch for the next outage; `Halted` and the
sentinel do **not** clear, which is an operator's decision.
A trip pulls resting orders and, at most, halts. It never sends a closing order, so a position open
when the link died is still open after the trip. Getting out is the operator's `market-exit`, usable
from a halted core precisely because a flatten leg is the position-covered reduce the halt admits —
see [halt](/docs/trader/concepts/halt). And because the halt is engaged, the strategy cannot re-open risk
when data returns until the operator clears it.
## What it costs replay [#what-it-costs-replay]
The boundary waker message is journaled only when a wall-clock sweep that mutates order state can
ride it, and there are now three such sweeps: the stuck-order [watchdog](/docs/trader/concepts/watchdog)
and both dead-man switches. That waker record is what makes `replay_from` return `Unsupported` for
the session: a trip is a wall-clock effect the journal cannot re-derive. A core with none of the
three on writes no waker record and replays normally; the rule is on the
[replay](/docs/trader/concepts/journal/replay) page.
In practice a live mount has already forfeited replay before either key is read — `live_mount_with`
sets a 30 s submit-acknowledgement timeout, which arms the same record — so arming either switch
forfeits nothing further. It is the paper and harness cores, which set no such timeout, where the
distinction is live.
Both switches are among the mechanisms that reach `Halted` on their own — the fold panic's
safe-state entry is another — which is why the halt policy has no mode that refuses a reducing
order: a switch that trips and then traps the operator in the position it just exposed would be
worse than no switch.
# Emulated orders (/docs/trader/concepts/emulated-orders)
`crates/vike-core/src/emulator.rs`'s `ConditionalBook` is the core-side emulation of
conditional orders — a fixed stop or a trailing stop — for venues, or strategies, without
native support. It is a port of the Python platform's conditional book, and its trigger
predicate is `vike_model::order_fill_price`, the same parity-pinned oracle the backtest engine
fires on, so an emulated stop triggers by exactly the law the
[backtest](/docs/trader/backtesting/fill-resolution) applies. This page is written from that module,
the `OrderIntent` arms in `crates/vike-exec/src/lanes.rs`, and the firing sites in
`crates/vike-core/src/runtime/strategy_drive.rs`.
## Arm, fire, disarm [#arm-fire-disarm]
Registration bypasses the gate; the fire goes through the one live path. `OrderIntent::
ArmConditional` carries a `ConditionalIntent` — venue, symbol, side, quantity, and either
`price` for a fixed stop trigger or `trail` for a trailing distance, whose extreme seeds from
the current mark at apply time and is refused into the recent-events ring when no mark exists.
The runtime mints an `arm_id` and inserts the arm; the book is keyed by that id, so a duplicate
is refused with the book unchanged and the resting arm is never silently replaced. Live ids
come from a monotone counter and cannot collide; the keying is the structural backstop.
When a check fires, each `FiredConditional` — arm id, side, quantity and the oracle's trigger
price, diagnostic only since the live fill is the venue's — is released by
`CoreThread::submit_fired` as a plain market request through `apply_intent`, so mint,
`RiskGate` and client stay structural on the release; a veto surfaces as `OrderDenied`.
`OrderIntent::DisarmConditional { arm_id }` drops exactly that arm; an unknown or stale id is a
loud no-op surfaced to recent-events, never a panic and never silent. Like arming, a disarm
bypasses the gate — removing a resting emulated arm is not an order reaching a venue.
`OrderIntent::MassCancel` with no venue and no symbol clears every conditional book along with
every resting order.
## The trigger law [#the-trigger-law]
A fixed stop fires when a bar crosses its price, under the gap-open adverse fill law: a bar
that gaps down through a sell-stop fills at the worse open, not the trigger. A trailing stop's
extreme ratchets in place on a no-fire bar, exactly as in the Python book and the backtest, and
the check runs against the *prior* extreme's trigger before ratcheting — so a new-high bar
cannot stop itself out. Fired arms are removed and returned; survivors keep their ratcheted
extremes. Walk and fire order is the book's insertion order, and a disarm in the middle does not
reorder the survivors (`shift_remove`, never `swap_remove`).
## Which price series fires it [#which-price-series-fires-it]
Each arm carries the `vike_model::TriggerBy` it was requested with, and every check names the
`PriceLane` its price came from; the two meet in `ArmedConditional::evaluates_on`. The type
exists because an unmodelled trigger source was a live-versus-backtest divergence: with last at
99 and mark at 101, a stop at 100 fires on a mark-triggering venue and on no last-triggering
one, and no emulator could reproduce the timing.
* `None` or `Last` arms evaluate on trade and bar prices — today's law, byte-identical.
`check_bar` runs against every closed bar of the mount's series, *before* the strategy step,
which is the oracle's firing order. With `CoreConfig::conditionals_on_ticks` (default
`false`, oracle-faithful bar-close only) the book is also checked against each tick of the
engine's series through a degenerate one-price bar — the intra-bar triggering the Python book
explicitly lacks.
* `Mark` arms evaluate only on the mark lane. `check_mark` is fed from the conflated mark drain
and is always on when a mark arm rests — deliberately not behind `conditionals_on_ticks`,
because the mark lane has no bar-close equivalent and gating it would mean a mark arm could
never fire. This is what lets the emulator reproduce a mark-triggering venue's timing. The
no-mark-arm path costs one integer compare per conflated tick (`has_mark_arms`).
* `Index` matches no lane: the core has no index lane, so the runtime refuses such an arm at
apply time, and one smuggled in anyway — a hand-edited journal — rests inert rather than
firing off the wrong series.
Arms on the other lane are untouched by a check: not fired, not ratcheted — their price series
simply did not tick.
A fire is a runtime reaction to a bar or tick, and market data is never journaled — so before
the write-ahead `ConditionalFire` record existed, replay re-folded a fired session without its
release and the determinism fence caught it as a hash mismatch. `submit_fired` now writes the
decision, not the tick behind it, before `apply_intent`, with the request's client order id
still empty; replay re-applies the recorded request through the same path and never
re-evaluates a trigger, so a fire can neither double nor vanish. Arm and disarm are journaled
the same way, and a restart re-arms the book exactly as it rested — a trailing arm's ratcheted
extreme included — from the journal's `Snap`. See [replay](/docs/trader/concepts/journal/replay).
## Whose account fires [#whose-account-fires]
`conditional_books` is keyed by `(venue, symbol)` — an exchange fact — so two accounts of one
venue arm into one book and a fired conditional names no account. The runtime records which
engine armed each id and routes the release to that engine by index; lowering by the payload's
venue string once resolved the venue's default account, so a labelled mount's protective stop
sold into the default account's book and opened a naked position while the one it was armed to
close stayed open. A book seeded from a restored `Snap` carries no route key and keeps the
payload route — the declared residual on that field. Multi-account mounts are the
[accounts](/docs/trader/concepts/accounts) page's subject.
## What it is not [#what-it-is-not]
A venue that supports conditional orders natively receives the `trigger_price` and `TriggerBy`
on the [order request](/docs/trader/concepts/orders/) itself, and an adapter that cannot express the
requested source on its wire must synthesize a terminal `OrderRejected` rather than silently
substitute a different series. Bracket exits held off the venue until their entry fills are a
different mechanism — the [contingency](/docs/trader/concepts/contingency) page's OTO/OCO resolver.
# The event bus (/docs/trader/concepts/event-bus)
Every venue event the [execution core](/docs/trader/concepts/execution-core) folds passes through one
queue: `crates/vike-exec/src/bus.rs`'s `EventBus`. It is small, and its header states the one
property that makes it more than a `VecDeque`: **defer-and-deliver**. A publish appends to the
back of the FIFO; if a drain is already running the call returns and the event waits its turn;
otherwise the call drains, delivering each event in full before the next one starts. The ordering
is load-bearing rather than tidy — the example the header gives is a one-cancels-other bracket,
where the auto-cancel publishes `OrderCanceled` *while* `OrderFilled` is being delivered, and the
cancel must land after every subscriber has seen the fill.
## One writer, one outbox [#one-writer-one-outbox]
The single-writer core owns both the bus and its handler, so callback re-entrancy is impossible by
construction. Instead of publishing back into the bus mid-fold, a handler pushes follow-on events
into an `Outbox`, and the drain loop appends that outbox to the back of the same FIFO after the
fold returns. Follow-ons are therefore delivered after every already-queued event and never
recursively — byte-identical delivery order to the original semantics, and clean under the borrow
checker. The `draining` latch is kept as an explicit field even though the single-writer core can
never set it re-entrantly; the parity test's chained publisher exercises it, and the semantics stay
written down.
`EventHandler` is the bus's one subscriber type, and `ExecutionEngine` is the handler: its `on_event`
folds the venue stream through the order state machine and the [account](/docs/trader/concepts/account),
and — as `crates/vike-exec/src/execution_engine/mod.rs`'s header says — it *folds state only*;
durable persistence belongs to the runtime's [journal](/docs/trader/concepts/journal/index). Every
delivered event is also appended to the bus's `delivered` log, which the runtime takes each batch
and folds into its bounded recent-events ring — the seam the desktop app's event echo reads through
a lossy observer channel, and the seam the fixtures assert delivery order against.
## The fold reports a verdict [#the-fold-reports-a-verdict]
`on_event` returns a `Fold`: `Applied` when the event actually changed state, `Dropped` when the
handler refused it — an unknown client-order-id, an illegal transition in the
[order lifecycle](/docs/trader/concepts/order-lifecycle), a reconnect replay, an event for a symbol or
venue this engine does not own, or a non-finite venue number. "Invalid transitions are dropped" is
a core law of the order management system, and the `Fold` doc records why the verdict had to become
visible: before it existed `on_event` returned nothing, so a downstream reaction keyed off the
*event* rather than off the fold's acceptance of it, and fired for events the engine had refused.
The live one-triggers-other / one-cancels-other drive was exactly that — a fabricated `OrderFilled`
on an unknown client-order-id cancelled its sibling and armed its held children even though the
engine had dropped the event and counted it into `dropped_unknown_coid`.
`publish` returns the verdict for **the event it was handed**. Follow-on events drained by the same
call carry their own verdicts, which are not reported: no caller reacts to them, and the site that
published them is the one that would. `crates/vike-core/src/runtime/mod.rs`'s `dispatch` names the
consumer this exists for — the [contingency](/docs/trader/concepts/contingency) drive on the venue
ingest lane is gated on the fold's verdict, because without that gate a fabricated fill on one
bracket leg cancels its sibling and leaves a position naked. The in-process client seam is
deliberately *not* gated.
When `publish` finds a drain already running it returns `Fold::Applied` without knowing anything.
That branch is structurally unreachable in the single-writer core — `draining` can only be set by a
`publish` higher in the same call stack, and the engine publishes into its outbox rather than back
into the bus — so it exists only for the parity test's chained publisher, and answering `Applied`
there keeps that path byte-identical to its pre-verdict behaviour; answering `Dropped` would invent
a new way to orphan a legitimate bracket on a path nobody exercises. A caller that genuinely needs
the verdict of a deferred event must not use this seam.
## What a panic mid-fold does [#what-a-panic-mid-fold-does]
The drain is unwind-safe in the way a `try/finally` would be: a drop guard clears the `draining`
latch on the way out, so after the runtime's `catch_unwind` the next publish drains normally, with
every undelivered event still queued in order. The `publish` doc records the review finding that
put it there — without the guard, one in-drain panic would silently defer every later event
forever.
The same guard does a second job. The event being folded is held in an `in_flight` slot during
`on_event`; a clean return moves it into `delivered`, so anything still in the slot when the guard
drops was lost to a panic, and the guard pushes it into a `poisoned` list instead of letting it go.
The runtime drains `take_poisoned` after its `catch_unwind` fires and surfaces each as a lost
terminal in the recent-events ring, so a panic can never silently strand an order — the test
`panic_mid_fold_salvages_the_in_flight_event` pins that the salvaged event is recorded exactly once
and is *not* re-folded. This is the bus's half of the core's panic policy; the runtime's half is on
the [execution core](/docs/trader/concepts/execution-core) page.
The bus consumes what the [ingest lanes](/docs/trader/concepts/ingest-lanes) deliver — the lossless
`EventSender` every venue adapter holds pushes canonical events into the core's single ingest
channel, and the drain loop publishes each one here. The variants themselves, who emits each and
what the state machine does with it, are one page each under [events](/docs/trader/concepts/events/).
# The ExecutionClient seam (/docs/trader/concepts/execution-client-seam)
Every venue bridge implements one trait: `vike_exec::ExecutionClient`, declared in
`crates/vike-exec/src/execution_engine/client.rs` and isolated from the engine so the contract
reads on its own. The engine holds it type-erased — `Box` — and never
knows which venue is behind it. That is the whole point: the core, the risk gate and the order
state machine are written once, and a venue's job is to translate. Each page under
[venues](/docs/trader/venues/) is one implementation of what this page describes.
## Two methods are required [#two-methods-are-required]
```rust
fn submit(&mut self, request: &OrderRequest);
fn cancel(&mut self, client_order_id: &str);
```
They are the only order-mutating calls that leave the core, and both are fire-and-forget. Neither
returns a result, because the venue's answer does not arrive on this call — it arrives later, as
an event, through the ingest lane the adapter was handed at construction. That is true of every
method on the trait: **every state change comes back as a venue event**, never as a return value.
## Everything else has a default [#everything-else-has-a-default]
| method | default behaviour | who overrides it, and why |
| ------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cancel_with_intent` | drops the `CancelIntent` and calls `cancel` | a venue that meters its cancels. **The engine calls this, never `cancel`**, so an override sees every cancel the core issues |
| `modify` | no-op — the order keeps its terms | a venue with an in-place amend. Takes the whole resting `OrderRequest`, not just its id, because some venues need side, quantity and price together |
| `submit_batch` / `cancel_batch` | fan out to the per-order call | a venue with a native batch endpoint |
| `cancel_batch_with_intent` | drops the intent and calls `cancel_batch` | a venue that wants the whole batch, with its intent, as a single unit; Polymarket is the only declaration in the tree, for the latency of an emergency flatten |
| `confirm` | no-op | a venue with an order-status re-query. The stuck-order watchdog is its caller; on a client that cannot re-query, the watchdog's last-resort reject still backstops it |
| `detach` | no-op | adapters with something to release |
| `poll_events` | `None` | in-process test clients only — a real adapter's events arrive over the ingest channel |
| `on_bar` | no-op | the paper exchange, which fills its resting book on each closed bar before the strategy runs |
| `amend_semantics` | `None` — "ask the venue table" | **the paper exchange only**, see below |
Two contracts hide in that table. `confirm` must run its re-query **off** the core fold thread, on
the adapter's own thread — it is the watchdog's way of prodding a wedged adapter into answering
rather than only waiting on it, and a blocking re-query here would stall the fold it exists to
protect. And the boxed impl delegates every method explicitly, because trait-object defaults would
otherwise shadow a concrete client's overrides: a boxed intent-aware client that fell through to a
default would silently report every cancel as unclassified.
### Why a cancel carries an intent [#why-a-cancel-carries-an-intent]
`CancelIntent` is the one cross-venue classification of *why* a cancel is being issued: `Routine`
(maker ladder churn), `RiskOff` (a declared flatten, market exit, dead-man trip or safe-state
sweep), or `Unspecified`. It exists because a venue-side cancel budget is a real liveness hazard —
a maker that spends its whole cancel bucket on requote churn is rate-limit-locked at the moment it
needs to pull a book — and holding a reserve back is only possible if the shedding decision can
tell the two apart.
A caller that cannot classify its cancel gets the treatment an emergency gets — fire it, never
hold it back — because the failure modes are not symmetric: shedding a routine cancel costs one
stale quote for one refill window, while shedding an emergency one leaves a live order resting in
the situation the operator was trying to get out of. `CancelIntent::may_be_shed` is the one place
that says only `Routine` may ever be held back, so a venue never re-derives the rule from a
`match` and is never one `_ =>` arm from inverting it. A venue that overrides `cancel_with_intent`
must write `cancel` in terms of it, or a direct `cancel` call silently skips the override.
## Where the adapter runs [#where-the-adapter-runs]
The trait is synchronous and the venue is not, so an adapter owns a thread. The shared shape is
`crates/vike-bridge-core/src/exec_actor.rs`'s `ExecActor`: an mpsc channel plus a dedicated OS
thread draining submit, cancel and shutdown, with a `Drop`/`detach` that sends shutdown and joins
the thread — deterministic teardown, because a stranded feed thread is a crash on process exit.
Each venue supplies only its command loop, and for the REST-driven crypto venues even that loop is
shared. Venue replies are pushed back to the core with `EventSender::blocking_send`. Dukascopy is
the one adapter that owns a child process instead — there is no REST execution API for that venue,
so a Java sidecar drives execution over JSON lines on stdio. In both cases the core thread never
waits on the wire.
The actor is also where the operator [halt](/docs/trader/concepts/halt) bites first: `submit` and
`modify` consult the halt file sentinel before touching the command channel. While it exists, a
submit the caller has not flagged `reduce_only` is refused with a synthesized terminal
`OrderRejected`, and a `reduce_only` submit is admitted — and logged, because this boundary holds
no position book and can only trust the flag. `modify` is blocked either way, since it can add size
or chase price and reduce-only cannot be inferred from the request alone; nothing is synthesized
there, the resting order simply keeps its terms. A cancel is deliberately never gated, and a cancel
alone would not discharge the rule: it removes a resting *order*, whereas leaving a *position*
means sending one, so a halt must let an operator reduce or exit, never trap a position. It works
even when the runtime is wedged.
## The emitter split [#the-emitter-split]
Who emits which event is fixed, not a per-venue choice. The Rust side emits `OrderSubmitted`
synchronously at submit. The venue side emits `OrderAccepted`, `OrderRejected`, `OrderCanceled`,
`OrderFilled` and `OrderPartiallyFilled`. A dead venue path — a transport that died mid-order, a
sidecar that never answered — must synthesize a terminal `OrderRejected` rather than let the order
vanish. The [order lifecycle](/docs/trader/concepts/order-lifecycle) page carries the three rules and
the harness that machine-checks them against every venue's real decoder.
## The one method a venue adapter should never override [#the-one-method-a-venue-adapter-should-never-override]
`amend_semantics` answers "what does *my* `modify`'s quantity mean" — the new total, or the new
remaining size — which is the fact `ExecutionEngine::modify_order` needs before it may net a
partially filled order's executed lots out of the pre-trade projection. `None` means "consult
`vike_model::amend_semantics` for the engine's venue string", which is the right answer for every
real venue, because that table is where the per-venue fact is declared and tested.
It exists because the engine's venue string is a **label**, not a client. `vike_mount::make_engine`
builds the engine with the real venue id even when the arming ceiling or the absent-credentials
gate fell back to `vike_paper::PaperExecutionClient`, so a paper mount on a venue whose amend is
in-place-total looks up that row while the paper book's `modify` assigns the amend quantity to the
order's **remaining** size — netting there would let the gate assume `q − filled` while the book
executes `q`. Declaring the convention on the client means it travels with the object that
implements it and no mount can forget to pass it. The
[paper exchange](/docs/trader/concepts/paper-exchange) is therefore the only override in the tree, and
the method's own documentation says so.
# The execution core (/docs/trader/concepts/execution-core)
`vike-core` is the live core. One dedicated OS thread — `vt-core` — owns the `ExecutionEngine`
(account, order registry, risk gate, venue client) and is the **only** mutator of any of it.
Everything else in the process reaches that state through one ingest queue, totally ordered, and
reads it back through an immutable snapshot. The authority for this page is
`crates/vike-core/src/runtime/mod.rs`, whose own header calls this the pinned architecture.
## Three lanes into one queue [#three-lanes-into-one-queue]
Three kinds of message arrive, and each has a different contract with its producer:
* **Exec events** — fills, order transitions, account and funding updates, liquidations — are
lossless. A venue adapter pushes them with `blocking_send`, so a slow core back-pressures the
venue task and never the GUI.
* **Commands** from the GUI or a control surface use `try_send` into the same queue. The GUI
thread never blocks. A full-queue rejection comes back to the caller as `CommandRejected::Busy`
*and* is counted in the snapshot as `rejected_commands`, so it cannot be silent;
`CommandRejected::Gone` means the core thread has exited.
* **Market data** goes through a latest-wins conflation slot plus a marker message. The producer
overwrites the slot (a drop counter is surfaced) and at most one marker is ever in flight, so a
slow core sees the freshest price without unbounded queueing.
On the core side the thread sits in `blocking_recv()` — there is no tokio runtime on the hot
thread — then drains opportunistically with a `try_recv` batch bounded by `CoreConfig::batch_max`
before publishing, which keeps latency bounded. The producer-side types for all three lanes live in
`crates/vike-exec/src/lanes.rs`, not in `vike-core`, so that a venue bridge depends on `vike-exec`
and never on the core above it; the [ingest lanes](/docs/trader/concepts/ingest-lanes) page is theirs.
## The snapshot is a lossy observer [#the-snapshot-is-a-lossy-observer]
The core publishes a `CoreSnapshot` into an `arc_swap` cell that the GUI reads on repaint. Publish
is dirty-flagged and coalesced: at least every `CoreConfig::snapshot_interval` while busy (about
16 ms by default — the one number on this page that is a live constant, held to the code by
`crates/vike-ops/tests/docs_constants_gate.rs`), and immediately when the queue goes idle. The
build never runs per-event *while busy*.
The idle-path publish is ungated by the interval. On a venue whose events arrive sporadically the
core is idle between them, so it publishes after every one. Anything `CoreSnapshot::build` does is
therefore per-event work, not per-interval work — which is why deferring a render "to publish
time" once turned out to be a pessimization rather than a saving: it relocated the work instead of
avoiding it (p99 2039 ns deferred, against 606 ns rendered at push and a 481 ns baseline).
## The fold is latency-gated [#the-fold-is-latency-gated]
The drain loop is the thread the core-hop tail gate measures —
`crates/vike-core/tests/runtime_latency.rs`, run in CI on a runner whose cores are reserved for
it. Every ceiling in that file is a derived constant with its data on it, and only three of its
harnesses gate (`baseline`, `journal`, `journal-snap`); the rest measure, asserting no timing
beyond the snapshot build's pre-existing 1 ms sanity check, because calibrating one ceiling there
cost 98 CI attempts and an uncalibrated ceiling is a flaky gate, which teaches people to re-run
rather than read. The
[latency gate](/docs/trader/concepts/latency-gate) page is where a number is read.
Two consequences follow for anyone touching the core. Nothing on the fold path may log
per-message — instrument per-order boundaries and fault transitions only. And everything that can
run off the fold belongs in `vike-ops`, which exists to consume the published snapshot, event and
journal streams without ever sitting in this loop.
## What happens when a handler panics [#what-happens-when-a-handler-panics]
Every dispatch runs under `catch_unwind`. On a panic the core does **not** die and does **not**
keep trading. It enters safe-state: `trading_state` becomes `Halted`, so the risk gate denies
every new order; a best-effort cancel is issued for every non-terminal working order; and the
fault is surfaced in the snapshot. A possibly-inconsistent engine is never traded on again — only
read for display — until the process restarts, which is what makes wrapping it in
`AssertUnwindSafe` sound.
## The drain-loop boundary [#the-drain-loop-boundary]
Cold-path work does not ride the per-message dispatch. It is armed on a `DeadlineTimerWheel` and
runs at the boundary of a drain, and `runtime/mod.rs`'s `TimerKind` is the list of what may fire
there: the stuck-order watchdog sweep, the equity sampler, the periodic strategy-state save, the
two dead-man's-switch sweeps — one per switch, each on its own window — managed good-till-date
expiry, the periodic portfolio journal record, and the fast in-flight confirm — the reject-free
early rung of the stuck-order ladder, which only re-queries and never terminalizes. Every one is
opt-in through `CoreConfig`, and when none is configured the wheel stays empty and the boundary
advance is skipped. The [timers](/docs/trader/concepts/timers), [watchdog](/docs/trader/concepts/watchdog) and
[dead-man's switches](/docs/trader/concepts/dead-mans-switch) pages carry each.
Two more pieces sit around the loop. `JournalConfig` is the opt-in write-ahead command journal:
`None` — the default — opens nothing and leaves the fold path byte-identical, while `Some`
journals every exec-lane message before it folds, with the segment, flush and snapshot cadence the
`[sinks.journal]` profile section uses. And the live strategy mount is the path through which a
`Strategy` drives a `LiveBroker` — the same mount whether the venue behind it is paper or live.
Each has its own pages; this one is only the thread they all hand their work to, and the
[portfolio and snapshot](/docs/trader/concepts/portfolio-and-snapshot) page is what comes out the other
side.
# Halt (/docs/trader/concepts/halt)
A halt in Vike Trader is a file. While the sentinel exists on disk, every submit that would
open or add to a position is refused at the venue adapter's submit boundary. An operator
engages it with `touch ` over ssh and the very next submit is refused — no IPC, no
running event loop, no responsive window, only a filesystem `exists()` check at the submit
seam. The sentinel is owned by `crates/vike-bridge-core/src/halt.rs`; the *decision* about what
a halt lets out is `crates/vike-exec/src/halt.rs`'s `halt_admits_submit`; the policy is
`crates/vike-model/src/halt_admit.rs`. This page is written from those three.
## Where the file is [#where-the-file-is]
The path resolves in three rungs: `VIKE_HALT_FILE` when set to a non-blank value; otherwise
`/settings/state/HALT`, the one directory this workspace writes program-owned state
into, with *which* project taken from the composition root's own boot walk so a deployment that
names its settings directory moves the sentinel with it; and, only when no project resolves at
all, `/HALT`. The resolution is probed once per process at mount time by
`halt_path_arming_error` — `error` when an operator could not create the file, `info` when
they could — because a kill switch that is not armed looks exactly like one that is, right up
until it is needed. Rung 3 is reported as a `warn` carrying `EXE_DIR_FALLBACK_ADVISORY`: in a
container the binary lives in the image while `` is a bind mount, so a sentinel beside
the binary is armable and an operator touching the project's file on the host arms one this
process never reads.
## What an engaged halt lets out [#what-an-engaged-halt-lets-out]
* **A submit that opens or adds is blocked.** A terminal `OrderRejected` is synthesized through
the normal event path with the exact reason `HALT_REJECT_REASON` = `halted: HALT file
present`, so the intent never silently vanishes.
* **A submit that reduces is admitted.** Under the default policy the whole rule is
`request.reduce_only`.
* **Cancel is always allowed.**
* **Modify is blocked** on every client: a modify can add size or chase price, and reduce-only
cannot be inferred from the request alone. The exit path under halt is a cancel or a reducing
submit, so blocking modify traps nobody.
The reducing arm exists because a cancel is not an exit. A cancel removes a resting *order*;
leaving a *position* requires sending one. `OrderIntent::Flatten` mints a `reduce_only` market
order for `|position|`, and until the exemption existed `market-exit` under a HALT file ran its
mass-cancel and then had every flatten leg rejected, leaving the operator halted with the
position still open. The promise the module doc states first is *halting cannot trap you*, and
it outranks *halting is airtight*.
`halt_admits_submit` is one shared function rather than an `if` in each client because several
independent implementations enforce the sentinel — the shared `ExecActor`, hyperliquid's
bespoke client and `vike_paper::PaperExecutionClient` — and the original pair had already
drifted once (both blocked `submit`, only one blocked `submit_batch`). It lives in `vike-exec`
because the paper client must refuse under a halt for the same reason a live one does — an
operator rehearses the switch on a paper daemon — and `vike-paper` may not grow a transport
dependency to reach it.
## Two mechanisms, one policy, different verification [#two-mechanisms-one-policy-different-verification]
The in-process twin is `vike_exec::RiskGate` under `TradingState::Halted`. It holds the
position book, so it admits only a *position-verified* reduce —
`crates/vike-model/src/scalar.rs`'s `is_covered_reduce`: the order must oppose a non-zero
position and be covered by it (`|position| >= |qty|`), or carry the flag *and* satisfy both
direction and coverage. A flat-book `reduce_only` order is an opening order there. The adapter
boundary holds no book — a command channel, an event lane and a sentinel path — so the
caller-asserted flag is the only evidence in existence, and `halt_admits_submit` takes it.
A strategy bug that tags its entries `reduce_only` can open risk through a HALT file on a flat
book, and nothing anywhere catches that. Three things bound it: every submit reaching the
boundary was minted by the core and has already passed the `RiskGate`; the gate's own
`Reducing` state trusts the same bare flag; and real perp venues reject a `reduce_only` order
that would increase a position — but only where a position exists to check against. The
admission is logged on every occurrence for exactly that reason.
## The `halt_admit` policy [#the-halt_admit-policy]
`policy.toml`'s `halt_admit` key (`vike_config::Policy::halt_admit`, on the
[policy settings](/docs/trader/reference/settings/policy) page) selects a `vike_model::HaltAdmit`
mode. `admit`, the default, is the flag-trusting rule above, byte-identical on every venue with
evidence ignored. `verify` is strictly tighter: still `reduce_only`, and additionally refused
when the venue's own position book *proves* the submit opens risk. `halt_admits_submit_under`
weighs a three-state `PositionEvidence` — `Unknown` with its reason, `Flat`, or
`Opposing(qty)` — and refuses only `Flat`. Every absence admits: never fetched, fetch failed,
an answer that could not be read in full or was for another account, socket dropped since,
poisoned lock, unresolvable symbol, and a book holding no position in the symbol at all.
`Opposing` admits at any magnitude, even smaller than the order, because the adapter caps an
oversized reduce and refusing it would deny a genuine partial exit. That `verify` admits only a
subset of what `admit` admits is pinned by `verify_is_a_subset_of_admit_over_every_input`.
`vike_model::halt_verify_support` says where `verify` is real. One venue answers `Real` today —
cTrader, the only adapter holding a position book at its halt boundary — and every other
roster venue degrades to `admit` with a written reason, reported once at mount by
`vike_mount::make_engine`; a cTrader mount that fell back to paper says so too, since the paper
client holds no book either. cTrader *extends* the predicate as `closes || admits`: a book is
evidence for a reduce and never against one, because it holds nothing until the venue answers a
reconcile, and a restarted daemon can have heard nothing about a position the account holds.
A `refuse` mode with no exemption was the behaviour until 2026-08-06 and was removed because it
trapped operators: halting denied `reduce_only` too, so the panic button was disarmed in exactly
the situations that reach a halt on their own — a fold panic, either
[dead-man's switch](/docs/trader/concepts/dead-mans-switch). `verify` degrades toward `admit` on
every unknown, never toward refusal, and an unknown spelling is rejected at load by name.
## What else engages it [#what-else-engages-it]
The file is the manual switch. Its automatic twins are the two
[dead-man's switches](/docs/trader/concepts/dead-mans-switch) — the connection one, armed unless
`policy.toml` writes `link_deadman_grace_ms = 0`, and the silence one, off unless that file arms it.
Either sets `trading_state = Halted` on every engine under the default `cancel_all_and_halt` action
and writes that same HALT file, so this sentinel can appear with nobody having touched anything;
what a trip is scoped to differs, but the halt half is process-wide in both. The connection switch
arms only for a venue whose bridge discloses a dead link on a lane this daemon subscribes, which is
narrower than its default suggests — its own page carries the per-venue reading. The
[watchdog](/docs/trader/concepts/watchdog)'s safe-state entry halts in-process after a fold panic. `OrderIntent::MarketExit` — the panic button — is
usable from a halted core, because a flatten leg is exactly the position-covered reduce the gate
admits; the one leg it still refuses is a position sitting off the lot grid, whose `|position|`
rounds up on the wire and would flip it. The check runs only at the submit and modify boundary,
never per message, so the [latency gate](/docs/trader/concepts/latency-gate) is untouched.
# The history store (/docs/trader/concepts/hist-store)
Every historical series Vike Trader records, backfills or replays goes through one seam:
`HistStore`, in `crates/vike-data/src/hist.rs` — one store for bars *and* ticks. The trait is
always compiled and depends on `vike-model` only; the concrete backend, `DataFusionHist`, is
DataFusion over Parquet behind the `hist-datafusion` feature — no SQLite, no server — partitioned
by UTC day, with compaction, retention and a manifest file-index that also drives reads, so nothing
lists a directory. Ingest is idempotent by **commit key**, never by row value: a key names the
source batch, and a batch already ingested for a series appends nothing, because trades carry no
id and idempotency has to live at the batch level. The same crate owns the
[live mirror of this seam](/docs/trader/concepts/live-data-seam), so a strategy reads bars the same way
whether they came off a socket or off disk.
The authority for the rest of this page is `crates/vike-data/src/store_kind.rs`.
## Thirteen kinds, one declared row each [#thirteen-kinds-one-declared-row-each]
The on-disk layout is a contract between many producers — the live recorder, the backfill
collectors, the journal materializer, a bridge writing a derived series — and three consumer
families: tick replay in the backtester, served reads in the data service, and the inventory views
in Data Manager and Studio. Until `STORE_KINDS` existed no site named that contract; each end
learned it by reading another end, and several shipped defects were exactly a producer and a
consumer disagreeing about the shape — a kind missing from the coverage view while its rows sat on
disk, five Data Manager verbs silently operating on a series that did not exist, a part-name
collision overwriting live parts.
`STORE_KINDS` declares one row per `kind=` naming what both ends must agree on: the domain row
type, the Arrow columns today's writer emits (with the codec flavour that decides nullability and
old-part tolerance), the Parquet schema-metadata key, the partitioning, whether a grouped form
exists, what `venue` and `symbol` actually *mean* for that kind, and the commit-key shapes its
known producers use.
| kind | grouped form | what it holds |
| -------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bar` | no | OHLCV bars, sub-partitioned by interval — the only kind that is |
| `quote` | yes | top-of-book quotes |
| `trade` | yes | the market print tape |
| `book` | yes | the lossless L2 lane: every delta, snapshot anchors and stream-health markers, `seq` contiguous |
| `depth` | no | the conflating L2 lane: periodic full snapshots with every intermediate state discarded — a different series on purpose, so a `book` reader never receives conflated data |
| `properties` | no | point-in-time `SymbolProperties` grids, about one row per symbol per day |
| `equity` | no | equity-curve samples — `venue` is the fixed namespace `portfolio` and `symbol` is a venue name or the `TOTAL` rollup |
| `exec_fill` | no | the account's own fills, a distinct kind from `trade` so the two never collide |
| `exec_order` | no | the account's own orders, as lifecycle snapshots |
| `funding` | no | the account's realized funding payments, keyed by coin |
| `chain` | no | option-chain snapshots, hundreds of rows sharing one `ts` |
| `cohort` | no | a graded positioning panel from a metrics service |
| `perp_metrics` | no | a perpetual's premium, plus open interest where a source supplies it — the narrowest kind in the store |
Each kind has its own page under [data](/docs/trader/concepts/data/), generated from this table; what
fills them is the [data providers](/docs/trader/data-providers/) tree.
`depth` is listed as groupable by the backfill crate's regroup table and has no grouped form
anywhere in the store — and no backfill source serves it at all, so a depth gap can only ever be
re-recorded live. And two unrelated series are both called *funding*: `kind=funding` is the
**account's** realized payments, while the **market** funding-rate history is a `kind=bar` series
under the reserved `interval=funding` label. Each is asserted by a test, so whoever resolves one is
told to update the table rather than leaving a stale warning behind. That is the workspace's
playbook for any per-thing capability table: declare today's reality first, byte-identical, and
flip behaviour one row at a time.
## The gate derives the live set from the code [#the-gate-derives-the-live-set-from-the-code]
`crates/vike-data/tests/store_kind_gate.rs` does not trust the table. It derives the set of kinds
the code actually writes three independent ways, because each is blind to a different mistake:
the first argument of every path-helper call in the backend, the `kind` strings each compaction
dispatch accepts, and the `append_*` verbs on the trait. It fails until every derived kind has a
row, then checks each row's columns and schema-metadata key against the codec they were read from.
Adding a kind the ordinary way reddens the trait check first, before anything writes a directory.
Its residuals are declared on the gate rather than implied: it cannot see dataflow, so a literal
reaching a directory through a helper it does not follow is invisible, and every such site carries
a written reason.
## A partition holds many parts [#a-partition-holds-many-parts]
Parts are `date=`-partitioned identically for every kind, and a part never spans a UTC day. That
does **not** mean one file per day: a writer seals one part per commit, the live recorder commits
per buffer flush, and compaction merges the results alongside. Measured read-only on a live tape,
one day's `kind=book` partition held 103 Parquet files and the previous day's 130. A consumer built
for one-file-per-day mishandles the real case, which is why `a_date_partition_holds_many_parts`
pins both halves. One more trap for a reader of old parts: a column with an `_add` flavour may be
absent entirely from a part sealed before it existed, so a consumer must never assume it is
present.
## Where the store lives when nobody said [#where-the-store-lives-when-nobody-said]
`DataFusionHist::open` takes the root it is given and creates it — it has no opinion about where
data belongs. That question is answered one layer up by
`crates/vike-model/src/store_path.rs`'s `resolve_store_root`, the one precedence every binary
resolves through, born from a cleanup that found six stores scattered over three directories on
two disks. From the top: an explicit `--store` or a profile's `store =`; then `$VIKE_HIST_STORE`,
the box's standing answer; then `/market_data/hist` for a project **declared** outright by
`$VIKE_SETTINGS_DIR`; then a developer's existing checkout store, if the checkout that built the
binary still exists; then `/market_data/hist` for a project **discovered** by walking up
from the working directory — the same walk that finds `/settings/`, so settings,
credentials and data can never disagree about which project this is, and the default a fresh
install gets; then a per-user data directory for a binary with no project above it at all. Every
answer comes back with the `StoreRootRung` that chose it, and every binary that resolves through it
logs both before opening anything — a tool handed a root outright, as `vike-study` and `tearsheet`
are by `--store`, opens it with no such line.
Point a tool at an empty directory and it creates a fresh, valid, **empty** store; the run then
reports zero rows, which is indistinguishable from "the backfill found nothing". A store does not
merge — if the answer moves, the old one is simply no longer read. That is why the resolver never
invents a path from the working directory, why every rung above the default is something a human
stated, and why the declared project outranks the checkout hinge: a declaration must not lose to
an inference. Under `systemd`'s `ProtectSystem=strict` a root outside `ReadWritePaths=` fails at
open, and the error is decorated with those two words — a diagnosis, deliberately not a pre-flight
probe.
# Concepts (/docs/trader/concepts)
The getting-started pages get you to a first backtest and a first paper mount without explaining
why anything is shaped the way it is. This section is the explanation. Every page states a
mechanism, names the code that is the authority for it, and says which test holds the invariant —
because on this platform most of the rules below are gates in CI rather than sentences in a
document, and a page that could not cite its claim was not written.
## Reading order [#reading-order]
The first eight pages build on each other and are best read in order:
1. [Architecture](/docs/trader/concepts/architecture) — a workspace whose dependency direction is
machine-checked, and the one startup sequence every binary runs.
2. [The execution core](/docs/trader/concepts/execution-core) — one thread owns all mutable trading
state; everything reaches it through one ordered queue.
3. [The ExecutionClient seam](/docs/trader/concepts/execution-client-seam) — the single trait a venue
bridge implements, and which of its methods are actually required.
4. [Order lifecycle](/docs/trader/concepts/order-lifecycle) — the state machine every order folds
through, and the three rules a venue may not break.
5. [Risk and arming](/docs/trader/concepts/risk-and-arming) — the arming ceiling read before any
credential, the credential gate behind it, the pre-trade gate on every order, and the halt.
6. [The data model](/docs/trader/concepts/data-model) — the value types every crate shares, and the
interning rule that decides which fields may be a pointer.
7. [The history store](/docs/trader/concepts/hist-store) — one Parquet store, thirteen declared
layouts, and where it lives when nobody said.
8. The three proofs: [the paper exchange](/docs/trader/concepts/paper-exchange),
[the parity gate](/docs/trader/concepts/parity-gate) and
[the latency gate](/docs/trader/concepts/latency-gate) — what is checked bit-for-bit, and what is
measured in microseconds.
## The rest of the section [#the-rest-of-the-section]
After that the pages stand alone, grouped by what they sit next to:
* **Inside the core thread** — [the event bus](/docs/trader/concepts/event-bus),
[ingest lanes](/docs/trader/concepts/ingest-lanes), [timers](/docs/trader/concepts/timers),
[portfolio and snapshot](/docs/trader/concepts/portfolio-and-snapshot),
[the control boundary](/docs/trader/concepts/control-boundary),
[the watchdog](/docs/trader/concepts/watchdog),
[the dead-man's switches](/docs/trader/concepts/dead-mans-switch),
[emulated orders](/docs/trader/concepts/emulated-orders).
* **Orders, money and stopping** — [the account](/docs/trader/concepts/account),
[contingent orders](/docs/trader/concepts/contingency), [halt](/docs/trader/concepts/halt),
[margin calls](/docs/trader/concepts/margin-call), [the price board](/docs/trader/concepts/price-board).
* **Mounting and startup** — [mounting](/docs/trader/concepts/mounting),
[accounts](/docs/trader/concepts/accounts), [startup preflight](/docs/trader/concepts/startup-preflight),
[settings](/docs/trader/concepts/settings), [the node protocol](/docs/trader/concepts/node-protocol).
* **Data in motion** — [the live data seam](/docs/trader/concepts/live-data-seam), the live mirror of
the history store.
* **Venue-free cores** — [options](/docs/trader/concepts/options) and
[alerting](/docs/trader/concepts/alerting).
Several rosters these pages mention are documented one member per page in their own sub-trees,
generated from the same CI-gated tables the runtime consults, so a page there cannot describe a
capability the code no longer has: [every event variant](/docs/trader/concepts/events/),
[every history store layout](/docs/trader/concepts/data/), [every order kind](/docs/trader/concepts/orders/),
[every asset class](/docs/trader/concepts/instruments/), and
[every reconciliation divergence kind](/docs/trader/concepts/reconciliation/). The command journal has a
hand-written sub-tree of its own.
A claim is cited by **symbol** — a repo-relative path plus a named identifier, joined by `'s` —
never by line number. `crates/vike-exec/src/order.rs`'s `ManagedOrder::apply` means: open that
file, find that symbol, read its own documentation; it is the authority, and the page is the
summary. The form is chosen because line numbers rot silently — when the product tree's own
citation gate was written, 26 of its 33 line-number citations were already wrong, every one of
them exactly right on the day it was authored — and that gate now rejects a `path:NNN` citation
whose path resolves to exactly one file in the tree, deliberately leaving a shorthand path it
cannot resolve alone.
# Ingest lanes (/docs/trader/concepts/ingest-lanes)
Everything that reaches the [execution core](/docs/trader/concepts/execution-core) arrives on one
ingest channel, and `crates/vike-exec/src/lanes.rs` owns the producer half of it: the sender
handles a venue adapter holds and the payload types they carry. The consumer — the single-writer
core thread — lives in `vike-core`. The header states why the split is where it is: venue bridges
depend on `vike-exec` but never on `vike-core`, so a bridge can push into the core without linking
the runtime that drains it.
## One message type, several disciplines [#one-message-type-several-disciplines]
`Ingest` is the one enum on the channel. Its variants sort into lanes by what losing a message
would mean:
* **`Event`** — a canonical venue event, the hot-path variant. It rides unboxed on purpose: the
comment beside the enum records that boxing it would trade a 240-byte move for a per-event heap
allocation on exactly the path the p99 \< 10 µs gate protects. Commands, which are rare, are boxed.
* **`Command`** — everything an outside thread may ask of the core, below.
* **`Market`** — a marker with no payload; the tick itself rides a conflation slot.
* **`BarSeed` / `BarClose`** — the bar lanes, lossless: a missed close is a hole in the series.
Forming-bar updates conflate with the ticks instead.
* **`Quote` / `Trade` / `Book`** — the tick lanes, lossless and dispatched per update to a mounted
strategy's tick handlers; they touch no parity-gated order-management fold path.
* **`Watchdog`** — the opt-in stuck-order sweep tick of the [watchdog](/docs/trader/concepts/watchdog).
* **`StreamStatus` / `Flow`** — occasional control events: a feed-health transition delivered to
`Strategy::on_feed_status`, and a per-side flow-toxicity update delivered to `Strategy::on_flow`.
Both fire on a transition or at their own cadence, never per market message; neither is journaled.
Every lossless sender returns `CoreGone` when the core thread has exited, so a producer learns the
channel is closed rather than blocking on it. The wait-free market lane reports nothing —
`MarketSender::publish` returns no result at all, and a marker that cannot be queued is simply
retried on the next publish.
## Lossless senders back-pressure the feed [#lossless-senders-back-pressure-the-feed]
`EventSender` is the lane every adapter pushes canonical events into — `send` from an async task,
`blocking_send` from a plain thread. Both back-pressure the producer and never drop. The handle
also carries an optional `route_key`, set by `EventSender::routed` at the mount: it stamps the key
onto the three venue-tagged payloads that carry no client-order-id — `AccountState`, `Funding` and
`PositionLiquidated` — so the core's router can fold them into the right engine when a venue runs
[more than one account](/docs/trader/concepts/accounts). `Fill` is deliberately left alone: it names its
order, which the router resolves exactly, and stamping it would put a second key on the one wire
shape the frozen parity fixtures pin. A key equal to the payload's own venue stamps nothing, which
keeps every single-account box's journal bytes identical. The stamp takes the event by `&mut`
because `blocking_send` sits inside the measured core hop — the
[latency gate](/docs/trader/concepts/latency-gate) feeds `Ingest::Event(Event::Fill)` through exactly
this call.
`BarSender` (`seed`, `close`) and `TickSender` (`quote`, `trade`, `book`, `stream_status`, `flow`)
are blocking-lossless for the same reason: each quote, trade or book update is a distinct event a
strategy may act on. The book rides behind an `Arc`, not by value — the `BookUpdate` doc records
the audit finding that carrying it by value deep-cloned two whole `BTreeMap`s (up to a 1000-level
seed) on the pump thread *and* paid the matching drop on the fold thread; with an `Arc` the send
is a refcount bump, a pump mutates its own standing copy through `Arc::make_mut`, and the wire
bytes are pinned identical to the pre-`Arc` shape.
## The market lane is latest-wins and wait-free [#the-market-lane-is-latest-wins-and-wait-free]
`MarketSender` is the exception, and the discipline inverts. A price tick for a (venue, symbol) is
written into a per-symbol slot — the newest overwrites, and an overwrite of a still-pending tick is
counted into `conflated_market_drops` — then at most one `Ingest::Market` marker is armed with
`try_send`. If the ingest queue is momentarily full the marker is not lost: `marker_in_flight`
stays false and the next publish retries it, and because ticks refresh continuously the slot can
never go stale while the feed is alive. The publish is wait-free and safe from tokio tasks and
plain threads alike. Three slot maps live under one lock — real venue marks, candle-close ticks and
forming bars — and marks and bar-closes are kept apart so a one-second mark stream can never
conflate away a kline feed's close for the same symbol; they fill different slots of the
[price board](/docs/trader/concepts/price-board) downstream.
## Commands, and the one write contract [#commands-and-the-one-write-contract]
`Command` is the session-control vocabulary: `Order`, `UpdateParams`, `SetTradingState`,
`SetMargin`, `ApplySnapshot`, `ReconcileReports`, `ConfirmRecon`, `MountStrategy`,
`UnmountStrategy`, `Shutdown`. These are control verbs rather than market messages, and
`UpdateParams`, `SetMargin`, `ReconcileReports` and the two mount verbs each say so in their own
doc — *occasional*, off the p99 event fold. The two mount verbs are deliberately not journaled,
because a mount-less replay core could never re-resolve a strategy. The
[control boundary](/docs/trader/concepts/control-boundary) is the surface these arrive through.
`Command::Order` wraps `OrderIntent` — **the one write contract**. Every order-scoped instruction,
whether from the external command path or from a mounted strategy's broker drain, lowers into the
core's single `apply_intent` site, so minting a client-order-id and crossing the risk gate are
structural on every order path. The verbs: `Submit`, `SubmitBatch`, `Cancel`, `CancelBatch`,
`Modify`, `Confirm`, `MassCancel`, `Flatten`, `MarketExit`, `Bracket`, `ArmConditional`,
`DisarmConditional` and `Combo` (declared on the wire; its own doc marks the lowering as pending).
Arming an [emulated conditional](/docs/trader/concepts/emulated-orders) bypasses the gate — only its
fire crosses it — and so does disarming one.
`MarketExit` is a compound verb: the runtime expands it at apply time into one `MassCancel` first,
then one `Flatten` per non-flat `BOTH`-side position — a hedge-mode long or short leg would resolve
a size of zero, so it is skipped — each lowered through the same `apply_intent` site. It has no
precondition — a position-covered reduce is admitted under [halt](/docs/trader/concepts/halt), which is
exactly the shape a flatten mints, unless the position sits off the lot grid, where the rounded-up
size no longer covers it and that leg is denied under `Halted`. Its own doc records that this is
the reverse of the rule the verb carried for its whole earlier life, when an operator had to
un-halt in order to escape. It is also best-effort: against a real venue the mass-cancel is
fire-and-forget over the adapter's actor thread, its acknowledgements may land after the flatten
orders are already on the wire, and a resting order can still fill after a flatten leg. Re-issue
the exit if the board is not flat afterwards.
Two test-support constructors ship with the lanes: `event_channel` hands back a raw `EventSender`
plus the receiver with no core behind it, so an adapter test can observe exactly what a client
pushes, and `market_data_channel` is its bar/market twin.
# The latency gate (/docs/trader/concepts/latency-gate)
The [execution core](/docs/trader/concepts/execution-core)'s drain loop is the one thread in the
platform with a latency budget, and the budget is enforced rather than aspired to:
`crates/vike-core/tests/runtime_latency.rs` runs in CI on a dedicated runner whose cores are
reserved for it, under real-time scheduling, and fails the run if the tail moves. This page is what
it measures, what it deliberately does not, and how to read a number from it without being misled.
## What is gated: the tail, on three variants [#what-is-gated-the-tail-on-three-variants]
Three tests assert derived ceilings — `baseline`, `journal` (the write-ahead append cost isolated,
no cadence snapshot) and `journal-snap` (the production journal configuration, snapshots included).
Each feeds `Ingest::Event(Event::Fill)` — the venue-reply lane — through the core 100 000 times and
asserts the whole **tail**, not a single p99. The harness header says why: until it did, the gate
computed p50, p99.9, max and the count of hops over 100 µs purely in order to *print* them, and a
reference run went green with a p99.9 of 37 741 ns and one 141.86 ms core hop. For a market maker
the tail is the number that costs money; p99 is the number that looks good.
Every ceiling is a derived constant with the measurements that produced it beside it in the
source. The reference row every constant descends from: baseline p50 271 ns, p99 551 ns, p99.9
2 104 ns, max 25 709 ns, zero hops over 100 µs. The baseline keeps the platform's long-standing
`P99_BUDGET_NS` of 10 µs, with `BASELINE_P999_NS` at 50 µs, `BASELINE_MAX_NS` at 2 ms — placed
between the shielded and un-shielded scheduling regimes, so it reddens if the shielding silently
stops applying — and `BASELINE_HOPS_OVER_100US` at 2. The two journal variants gate on a separately
calibrated `JOURNAL_P99_NS` of 20 µs: the 10 µs literal was never derived for them, and the main
branch itself measured 9 809 ns against it — 98.1 % of the bar, on code containing no change under
test. Deriving that one constant honestly took **98 CI attempts** harvested across 250 runs.
`JOURNAL_P999_NS` (100 µs), `JOURNAL_MAX_NS` (10 ms) and `JOURNAL_HOPS_OVER_100US` (50) record
today's measured tail so it stops being invisible; they are meant to be lowered as the journal's
I/O policy improves, and they move together or not at all. They have been lowered once — from
250 µs, 500 ms and 100 — after two fixes took blocking `msync` calls off the fold thread. The hop
count is the one that catches that defect as arithmetic rather than weather: a run at the
production `snapshot_every` takes 100 000 / 1024 = 97 cadence snapshots, and the pre-fix reps read
102, 97, 101 and 120 hops over 100 µs — one per snapshot. Read that number first on any red, and
read p50 before anything: a p50 that doubles means the box, not the code. The
[sync policy](/docs/trader/concepts/journal/sync-policy) is where the fix lives.
The order-submit lane, the book hop and the client-order-id variants are measured and assert
nothing about timing at all. The snapshot publish is measured too, at three order counts — and
keeps one pre-existing sanity assert, the 64-order build's p50 under 1 ms, roughly 100x above the
\~10 µs it actually measures. That is a liveness check against the build doing something
structurally different, not a derived budget. No ceiling here descends from a calibrated baseline,
on purpose. An uncalibrated ceiling is a flaky gate, which is worse than no gate at all, because
people learn to re-run it rather than read it. A measured, persisted, trendable series catches
drift for an afternoon's work and leaves the ceiling as a later decision derived from a baseline
instead of guessed. **A new measurement variant is not a budget in waiting; a ceiling means
somebody derived one.**
One path is deliberately not measured, and the harness records why: the live recorder's flush is
a disk-bound Parquet write on its own thread, and the failure that actually cost \~110k rows was a
compactor holding the series lock while the sink discarded batches — which shows up in
`RecorderHandle`'s `dropped` and `discarded` **counters**, not as a slow flush.
## Two facts that mislead everyone once [#two-facts-that-mislead-everyone-once]
**Serial execution is load-bearing, not tidiness.** The harness runs under `--test-threads=1`,
and `crates/vike-core/tests/runtime_latency.rs`'s `SPIN_DEADLINE` carries the measurement: every
harness thread busy-spins waiting for its worker, and CI pins the binary to a small reserved core
set under real-time scheduling. Run in parallel, the spinners occupy every core and starve the
workers they are waiting on — priority inversion, not slowness, and a spin that never terminates is
a `SCHED_FIFO` process nothing can preempt: this gate's history holds a livelocked binary at
**380 % CPU** with no progress for over five minutes, during which a `/proc` scan on the same host
went from 53 ms to over 120 s and degraded everything else running there. Serial execution also
makes the numbers honest: run in parallel, these tests contend for the very cores whose tail they
report.
**Publish is per-event work on a quiet venue.** The core publishes its snapshot on a coalesced
cadence while busy and immediately when the queue goes idle. On a venue whose events arrive
sporadically, that is a publish after every event — so anything `CoreSnapshot::build` does is on
the per-event path this gate measures, and deferring work "to publish time" relocates it rather
than avoiding it. Not hypothetical: rendering the recent-events ring lazily at publish moved the
baseline p99 from 481 ns to 2 039 ns; rendering at push again brought it to 606 ns.
## Reading a number [#reading-a-number]
Every run — pass or fail — appends one JSON row per (run, attempt, variant) to a persisted series
on the CI box, keyed on commit, with the attempt's journal-free `baseline_p99_ns` denormalized onto
it. That baseline is the canary no journal change can move, and it is the field that separates a
disturbed box from a real regression. Two things to know before quoting a figure: across the
ordinary load range the median barely moves while the tail does — baseline p50 read 271–451 ns
across every load bucket measured — so a four- or six-figure "p50" quoted anywhere is a tail
percentile that has been mislabelled, and that same stability is what makes the doubled-p50 tell
above work, since it takes a genuinely disturbed box to shift a median load alone leaves alone (a
p50 that tracks the change under test is real signal, and the journal fixes moved it); and the
series is the answer, not the job log — before it existed, "is this creeping on main?" cost two
separate hand-harvests of the CI API.
Nothing on the fold path may log per-message — instrument per-order boundaries and fault
transitions only. Everything that can run off the fold belongs in a crate that consumes the
published [snapshot](/docs/trader/concepts/portfolio-and-snapshot), event and
[journal](/docs/trader/concepts/journal/) streams without sitting in this loop. An honest A/B of a
hot-path change is run on the reserved-core box, serially, interleaving the variants and taking
medians of at least eight reps — single runs produced contradictory rankings all day. The
procedure is on [running the latency gate](/docs/trader/developer/testing/latency).
# The live data seam (/docs/trader/concepts/live-data-seam)
`crates/vike-data/src/live.rs` is the live twin of the [history store](/docs/trader/concepts/hist-store):
`DataClient` is what a venue feed implements to start and stop streams, and `LiveDataSink` is where
everything a stream produces lands. The contract carried over from an earlier prototype is
**fire-and-forget**. `subscribe_bars`, `subscribe_quotes`, `subscribe_trades`, `subscribe_book` and
`subscribe_depth` start a stream and return a `SubscriptionId` immediately; they never carry data.
All data returns through the one sink handed to the client at construction — the trait methods take
no sink parameter, and there is no subscribe-with-a-callback shape. An `Err` from a subscribe call
means the stream could not be *started*; a disconnect, a reconnect or a resubscribe after a drop
surfaces on the feed's own status path, never through this trait. Reconnect is the feed's concern,
not the seam's.
## Capabilities, not obligations [#capabilities-not-obligations]
A client that cannot serve a verb returns `LiveDataError::Unsupported` rather than pretending to
subscribe. `require_live_verb` drives that refusal off the venue's declared `VenueCaps.live_data`
row, so a feed that calls it first in every `subscribe_*` verb cannot drift from the capability
matrix, and an unknown venue string refuses every verb. Most bridge feeds do call it; a few still
hand-roll their own decision — hyperliquid's snapshot-only `subscribe_book` and polymarket's
`subscribe_bars` are the ones left — and routing those through the seam is the noted follow-up.
`subscribe_depth` — the DOM's conflating snapshot lane, distinct from the lossless tick-lane
book — is `Unsupported` by default and only venues with a live depth feed override it.
## Two lanes, and which price verb a feed must use [#two-lanes-and-which-price-verb-a-feed-must-use]
Sink methods are called from feed worker threads on every closed bar, intrabar update and tick, so
they must be cheap and effectively non-blocking — a slow sink stalls the feed. Downstream is a
lossless bar lane, `seed_bars` and `close_bar`, where a missed close is a series hole, plus a
latest-wins conflating lane, `forming_bar`, `bar_close_tick` and `mark_tick`, where drops are fine
and only the newest snapshot matters.
The price verbs are not interchangeable, because each fills a different rung of the
[price board](/docs/trader/concepts/price-board)'s resolver chain, and that chain is strictly ordered:
`mark` above side-aware `bid`/`ask` above `last_trade` above `bar_close`. A feed choosing the wrong
verb does not mislabel a number; it re-ranks it against every other price for the symbol. The
mapping is fixed:
| verb | what may call it | rung |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `mark_tick` | only a venue-published mark or index stream — binance-family `@markPrice@1s`, bybit `tickers.markPrice`, okx `mark-price`, hyperliquid `activeAssetCtx.markPx` | mark |
| `quote` | a BBO or two-sided book top | bid / ask |
| `trade` | a real print | last trade |
| `bar_close_tick` | a kline feed's candle close, from every venue that serves klines | bar close — the lowest rung, up to one full interval stale |
Mark streams are default on for perps; aster's is default off, its grammar unverified, and opts in
with `VIKE_MARK_STREAMS_ASTER=1`; `VIKE_MARK_STREAMS=0` is the master kill. The classes overlap
freely: a venue with no mark stream still fills the quote and trade rungs whenever those
subscriptions are open, so such a symbol is valued at its live quote or last trade rather than its
candle close — the intended law.
The L2 verbs split the same way. `book` carries folded state behind an `Arc` — a pump keeping one
standing book hands out clones and mutates through `Arc::make_mut`, so an applied delta no longer
deep-clones two whole maps per emission — while `book_update` is the recordable twin carrying the
raw delta, snapshot anchor or status marker that only recording sinks opt into; today the polymarket
pump in book mode is the one feed that emits it. `l2_snapshot` carries the instrument's `tick_size`
so a book-consuming sink quantizes on the grid the feed used rather than re-inferring it from a
truncated level set.
## Stream health [#stream-health]
`stream_status` is a default no-op that a feed's idle watchdog and reconnect path fire. `GapStart`
says the stream can no longer be trusted from a timestamp: for tick lanes the data in the gap is
missing and unrecoverable, for the forming-bar lane it is stale, and the lossless bar lane is
repaired by the re-seed before `Live` fires. `Stale` is the case a socket-liveness watchdog cannot
see — the transport is alive, keepalives arrive, and no fresh data has come inside the
subscription's freshness threshold. Both can be true at once, and only the consumer can decide what
staleness means: flatten, re-arm, widen.
A feed thread learns it should stop on its next socket read timeout, 2 s on every on-driver venue.
`DataClient::begin_shutdown` raises every stop flag and joins nothing, so a caller holding several
clients runs that phase across all of them before any join; `shutdown` then joins. `vike-recorder`'s
teardown used to stop one stream at a time, outside its bounded region, and its total stop time was
on a collision course with the unit's `TimeoutStopSec=` as a profile grew — a binance subscription
is two threads per symbol. `FeedRegistry` is the shared per-subscription registry behind that: one
stop flag and one `JoinHandle` per stream, nothing ever detached, with an optional hook that runs on
each spawned thread — the seam a venue threads its core-affinity pin through without this crate
depending on `vike-exec`. Bybit, binance, okx, hyperliquid and polymarket use it, and so do
deribit, ig and oanda.
`TeeSink` fans one feed's sink slot out to several destinations in order — the core ingest adapter
plus a tick-recording sink is the shipped composition — and must forward every verb explicitly:
inheriting a trait default for `bar_close_tick`, `l2_snapshot` or `book_update` would silently
drop candle-close ticks, depth snapshots or recordable book events. On the consuming side, the
desktop app's `CoreSinkAdapter` bridges the sink onto the core's bar and market
[ingest lanes](/docs/trader/concepts/ingest-lanes), and the `RecorderSink` writer actor behind the
`hist-datafusion` feature buffers live quotes, trades and book events into the store — the path
[recording a feed](/docs/trader/guides/record-a-feed) rides.
# Margin calls (/docs/trader/concepts/margin-call)
The margin-call model is `crates/vike-exec/src/margin_call.rs`'s `check_margin_call_priced`.
It is *pure*: it computes intents, and the caller — the core's
[watchdog](/docs/trader/concepts/watchdog), opted in through `CoreConfig::margin_call` — mints the
client order ids and submits through the normal gate path. A veto surfaces as `OrderDenied`; a
margin call can never bypass the `RiskGate`. The buying-power primitives are
`crates/vike-model/src/margin.rs`, and the breach law is `crates/vike-model/src/liquidation.rs`.
This page is written from those three modules.
## The three verdicts [#the-three-verdicts]
`MarginCallConfig` carries three fractions, defaulting to `mm_requirement: 0.05` (the
maintenance-margin fraction), `warn_fraction: 0.05` and `buffer: 0.10`. One sweep returns
`MarginCall::Healthy`; `Warning { margin_used, margin_remaining }` when remaining margin is at
or below `equity × warn_fraction` but nothing is liquidatable yet; or `Liquidate(intents)`. A
`LiquidationIntent` is a position-reducing market order — venue, symbol, position side, the
closing side opposite the position's sign, and a quantity. An unmarked position contributes no
margin and is never liquidated.
## One law, the pool is the parameter [#one-law-the-pool-is-the-parameter]
`vike_model::pool_breached` is the breach predicate for every pool: breached only when
`pool_maintenance > 0`, `pool_equity − pool_maintenance ≤ 0`, and
`pool_maintenance > pool_equity × (1 + buffer)`. A `NaN` anywhere reads as not breached — the
law refuses to liquidate on garbage inputs. It takes the folded maintenance product rather than
`(rate, notional)` because `Σ(|q|·mark·mult·rate)` and `rate·Σ(|q|·mark·mult)` differ in
`f64`, and the live verdict had to stay byte-identical to its pre-law behaviour.
Positions are partitioned by `vike_model::MarginMode` through `vike_model::partition_pools`:
* **Cross** positions — the default, every position today — share one pool. A breach runs
`vike_model::cross_liquidation_plan`: sort by unrealized PnL ascending so the biggest losers
go first (a stable sort, so ties keep insertion order), liquidate only the excess over the
line position by position, and stop as soon as the pool is healthy. A cross-only account is
byte-identical to the pre-partition sweep, pinned by
`cross_only_verdicts_byte_identical_to_legacy`.
* **Isolated** positions each form their own pool — the walled-off wallet plus their own
unrealized PnL, judged with a zero buffer. A breach closes that position only, full size, and
can never drag the cross pool: its wallet and PnL are subtracted from the cross pool's equity.
An isolated position with no reported wallet, or no mark, is skipped, and isolated pools have
no `Warning` arm.
* **Cash** positions never liquidate; fully funded, they structurally cannot breach.
Intents come out cross plan first, then isolated closes — one deterministic release order for
the journal-then-submit loop.
`cfg.mm_requirement` prices every pool. A per-symbol venue-reported maintenance rate would take
precedence when present, but no venue adapter parses one today, so the `SymbolProperties`
carrier is a documented follow-up rather than a guessed wire field. The GUI's liquidation badge
reads the same source — the operator's watchdog config when armed, else that config's default.
## Priced the way the operator sees it [#priced-the-way-the-operator-sees-it]
The live watchdog passes a resolver closure over the engine's
[price board](/docs/trader/concepts/price-board), so margin used shares the price basis of the
resolver-priced equity it is compared against: every price read in the sweep — the cross
margin-used fold, each pool's unrealized PnL, the isolated maintenance line, the cross
candidates — goes through the one `price_of`, and a stale mark can no longer overstate margin
against a fresh-quote equity or vice versa. The legacy `check_margin_call` prices at
`Account.marks` for callers with no board, such as tests and offline tools. The equity itself
is `ExecutionEngine::resolved_equity`, the scalar every decision path reads so an
auto-liquidation acts on exactly the number the snapshot displays.
A venue can report a position isolated while surfacing no per-position wallet. The sweep then
subtracts only that position's unrealized PnL, skips its isolated judgment (never a spurious
close), and leaves the unknown wallet folded inside the account balance — so the cross pool's
equity is overstated by that wallet and a genuine cross breach is judged late. The venue itself
still liquidates the isolated position. The module records a live probe of 2026-07-19 in which
both isolation paths were refused on the demo account, so the field semantics stayed
unverifiable and `isolated_margin: None` remains the pinned parse.
## When and where it runs [#when-and-where-it-runs]
The sweep runs once per closed bar of the engine's own series, where marks are fresh and the
path is the per-bar lane rather than the event fold, so the
[latency gate](/docs/trader/concepts/latency-gate) is untouched. `CoreThread::sweep_margin_call`
runs the model per engine: each account is checked at its own seed and its liquidations are
routed to that engine by index (`EngineRoute::Engine`) rather than by venue string — two
accounts of one exchange share a venue string, and routing by it once sent a reduce-only close
of account B's position to account A. A `Warning` lands in the recent-events ring; each
liquidation is a `reduce_only` market request journaled write-ahead
(`append_margin_call_liquidate`) before `apply_intent`, with no mount id, so its fill lands in
the [portfolio](/docs/trader/concepts/portfolio-and-snapshot)'s unattributed residual row. Without
that record a replay could not reproduce the liquidation, and the determinism fence caught it
as a hash mismatch.
## The buying-power primitives [#the-buying-power-primitives]
`crates/vike-model/src/margin.rs` is the venue-free core, as pure `f64` functions. Quantities
are signed, margins are in account currency, `rate` converts the quote currency to the account
currency (`1.0` when the same), and `im_req`/`mm_req` are fractions — `0.1` means 10×. Callers
own fee handling and pass a fee-adjusted requirement in.
* `initial_margin` — `rate × mult × price × qty × im_req`, signed, negative for shorts.
* `maintenance_margin` — `|qty| × price × mult × rate × mm_req`.
* `free_buying_power` — equity minus margin in use, plus the credit a direction-reversing order
frees, minus the required-free haircut, floored at zero.
* `amount_to_order` — the signed quantity that lands the position's margin at or under a target
on the lot grid; `0.0` on degenerate inputs or non-convergence, the safe verdict.
* `liquidation_price` — the isolated linear-perp mark: long `avg × (1 − im) / (1 − mm)`, short
`avg × (1 + im) / (1 + mm)`; `0.0` means no liquidation by price.
* `clamp_leverage` — request and cap both floored at `1.0`, so a sub-unity cap can never pull
effective leverage below one.
The snapshot's `VenueBlock` publishes `margin_used`, `free_bp` and `margin_ratio` from the same
primitives, computed only when the margin gate is armed; when it is off `free_bp` equals equity
and the ratio is zero. Each `PositionView::liq_price` uses `liquidation_price` for an isolated
position, `vike_model::cross_liquidation_price_est` for a cross one — the shared-pool line with
every other position frozen, advisory — and `0.0` for cash. The gate that admits orders in the
first place is the [risk and arming](/docs/trader/concepts/risk-and-arming) page's subject.
# Mounting (/docs/trader/concepts/mounting)
Three modules in `vike-core` answer three questions about a mounted strategy: what assembly it runs
inside (`crates/vike-core/src/run_profile.rs`), what it is called and where its durable state lives
(`crates/vike-core/src/strategy_state.rs`), and how a mount added at runtime comes back after a
restart (`crates/vike-core/src/mount_topology.rs`).
## The run profile [#the-run-profile]
`RunProfile` is the single TOML-deserializable artifact that names a full runtime assembly — event
source, broker, sinks, and the risk and guard stack — so that backtest, paper and live become one
auditable file instead of code-only `CoreConfig` construction by each binary. `mode` is `backtest`,
`paper` or `live`; `[event_source]` is `hist`, `replay` or `live_venue`; `[broker]` is `paper` or
`venue`. `RunProfile::validate` refuses the combinations that do not make sense — backtest needs a
paper broker and cannot take a live venue feed, live needs a venue broker and a live venue feed.
Every struct is `deny_unknown_fields`, so a typo'd key is a hard parse error rather than, in the
module's words, a silently ignored `max_levarage`.
The sections map onto real types and the mapping is compile-checked: `[risk]` becomes
`vike_exec::RiskLimits`, `[guards]` fills `CoreConfig` knobs — the stuck-order
[watchdog](/docs/trader/concepts/watchdog) timeouts, the drawdown latch, the margin-call config — and
`[sinks.journal]` becomes the [journal](/docs/trader/concepts/journal/) config.
`apply_guards_and_sinks` returns a `GuardsReport` naming every key that is set and still reaches
nothing, and evaluates the confirm-grace pairing on the final pair the mount will run with; that
check warns rather than refuses, and `ConfirmGraceHazard`'s doc argues why against the
degrade-versus-refuse record.
`RiskLimits` holds both concerns in one struct — `tick_size`/`lot_size`/`min_qty`/`min_notional`
from the venue's fetched grid, and the notional, exposure, rate and leverage caps from the operator.
`RunProfile::grid_source` derives which is allowed from `mode` alone: a `backtest`/`paper` profile
never fetches a grid and may supply one, a `live` profile always fetches one and may not, and
`validate` rejects a `live` profile whose `[risk]` sets any venue-owned field, unconditionally, at
load. A live mount additionally refuses to start unless `max_notional_per_order` and
`max_total_exposure` were supplied — `vike_mount::MountError::MissingRiskBudget` names every missing
key in one message, because no universal safe default exists for an account-dependent cap.
`resolve_profile` takes an explicit path (a `--profile` flag) over `VIKE_RUN_PROFILE` from an
injected variables map, never the process environment, and a resolved path that fails to read, parse
or validate is an error rather than a silent fall-through to built-in defaults. Three sample
profiles ship as string constants under `run_profile::samples`, one per mode.
## Mount identity and the state sidecar [#mount-identity-and-the-state-sidecar]
`strategy_state::mount_id_of(venue, symbol, interval)` sanitizes each part (every non-alphanumeric
character becomes `_`) and joins them with `__` — `mount_id_of("binance", "BTC/USDT", "1m")` is
`binance__BTC_USDT__1m`. It is pure and total, so a restart derives the same id a prior run wrote.
`mount_id_with` adds the one thing the derivation cannot express: an explicit `controller_id`, when
the mount carries one, **is** the mount id, sanitized the same way — which is how two strategies can
sit on the same `(venue, symbol, interval)` triple without sharing one sidecar and one journal
identity. `None`, empty and whitespace-only ids fall back to the derivation verbatim.
The sidecar is `/.json`. The runtime loads it when a strategy is mounted
(`Strategy::load_state`) and writes it on a clean shutdown (`Strategy::save_state`), both gated
behind the opt-in `CoreConfig::state_dir` — `None` by default, in which case nothing in the module
is ever called. `write_json_atomic` writes a `.tmp` sibling, `fsync`s it, then renames over the
final path with a remove-then-retry fallback for platforms where `rename` refuses to replace.
`read_json` is fail-open: a missing or corrupt sidecar reads as "no saved state", never a load-time
crash. A periodic save also runs while any mount exists when `CoreConfig::state_save` is set — and
unlike the equity sampler it does not wait for an open position, because a breaker or an estimator
matters whether the book is flat or not.
## Runtime mounts survive a restart — through a sidecar, not the journal [#runtime-mounts-survive-a-restart--through-a-sidecar-not-the-journal]
A mount added over the wire (`WireCommand::MountStrategy`, behind the node's
`strategy-mount-verbs` feature) is not a journal record. `Command::MountStrategy` and
`Command::UnmountStrategy` are excluded from the journaled set, and `mount_topology.rs` explains why
the exclusion stands: the journal is the [replay](/docs/trader/concepts/journal/replay) determinism
fence, and the replay core spawns with no strategy factory — `vike-core` sits below the strategy
crates and can resolve nothing — so a journaled mount could only ever replay as a refusal note.
Topology is session state, not order state.
So it lives in `runtime_mounts.json` beside the state sidecars, under the same opt-in `state_dir`,
invisible to the state hash and the replay core. The daemon reads the file after its core spawns and
before feeds arm, and re-sends each record as an ordinary `Command::MountStrategy` through the same
command lane a wire mount takes, meeting the same refusals. A record holds the spec exactly as the
command carried it — what to ask the factory for, never what the factory answered — so a
resurrected mount meets today's registry, today's script and today's refusals.
The lifecycle is asymmetric on purpose. A successful mount upserts its record; an explicit unmount
removes it; a clean shutdown removes nothing, so a runtime mount survives `systemctl restart`
exactly as it survives a crash — "the operator said stop the daemon" is not "the operator said
unmount". Profile mounts never enter the file, because the profile re-creates them at every boot. An
absent file is the ordinary empty state; a file that exists but does not parse is warned about
loudly and read as empty, so a corrupt topology can never refuse a boot, and the next successful
mount rewrites the whole list.
The only writer is the single-writer fold thread of one core. Two cores pointed at one `state_dir`
already share and clobber each other's `.json` sidecars and would share the topology file
the same way; the journal's [duplicate-instance interlock](/docs/trader/concepts/journal/locking) is what
makes that deployment shape loud. An unmount over the wire cancels that mount's attributed live
orders and saves its durable state before removal; positions are deliberately not flattened. Which
[account](/docs/trader/concepts/accounts) a mount trades on is part of its spec and is persisted with it.
# The node protocol (/docs/trader/concepts/node-protocol)
`vike-tradehub-client` is the light crate a thin client links to reach a headless node. It carries
no server and no DataFusion; what it defines is the wire schema (`proto.rs`), the auth primitive
bound to this service (`auth.rs`), and standalone mirrors of the snapshot and command vocabulary
(`wire.rs`). The server half lives in the [daemon](/docs/trader/reference/binaries/vike-tradehub).
## Framing [#framing]
A frame is `[u32 len][len bytes of UTF-8 JSON]`, big-endian, and the codec is re-exported from
`vike_datahub_client::proto` rather than re-implemented: both localhost services — the
[data service](/docs/trader/reference/binaries/vike-datahub) and the live-core node — speak the identical
frame with the same pre-allocation guard, `MAX_FRAME_LEN`. Two primitives matter to a server.
`read_frame_raw` returns the framed body without decoding, so a well-framed body that fails to
decode into a known request is answered with `Response::Error` and the connection survives — a
decode error is a bad request, not a bad connection. `read_frame_raw_capped` reads pre-auth frames
under a ceiling far below `MAX_FRAME_LEN`, so an unauthenticated peer cannot make the node allocate
from a four-byte length prefix.
## The handshake [#the-handshake]
The core is a headless process that accepts orders, so an unauthenticated peer must never reach the
command path. The handshake is a nonce-challenge HMAC:
1. Client → `Request::Hello { proto_version }`.
2. Server → `Response::Welcome { proto_version, nonce, features }` — `nonce` is 32 fresh random
bytes for this connection.
3. Client → `Request::Auth { scope, mac }`, where the tag is
`auth::sign(key_for(scope), &nonce, proto_version, scope)`.
4. Server recomputes and compares in constant time: `AuthOk { scope }`, or `AuthDenied { reason }`
with a reason that never leaks key material. Only after `AuthOk` may the client subscribe, ask
for a snapshot, or — with `Scope::Control` — send a command.
The signed message is `b"vike-tradehub-auth\0"` (a 19-byte, NUL-terminated domain separator), the
protocol version as a big-endian `u32`, one scope byte (`Observe` 0x00, `Control` 0x01), and the
nonce; the tag is HMAC-SHA256. Each field buys a property. The separator makes the preimage disjoint
from every other HMAC the workspace signs — the venue request signers, and the data service's own
`vike-datahub-auth\0` — so a tag captured off one surface is useless at the other. The scope byte
binds capability **into** the signature. The nonce binds the tag to one connection, so a captured
transcript cannot be replayed. The version in the message means a client and server that disagree
on it can never produce a matching tag. Verification uses the `hmac` crate's constant-time
`verify_slice`, never a byte-wise `==`.
The two scopes sign under different keys. `Scope::Observe` is snapshots and subscriptions;
`Scope::Control` additionally admits commands; and because an observe-only key cannot produce a
valid control tag, capability is cryptographic rather than a claim the server trusts. The keys are
`VIKE_TRADEHUB_OBSERVE_KEY` and `VIKE_TRADEHUB_CONTROL_KEY`, read by `auth::from_vars` from the
credential-store map the binary already loaded — not the process environment, where a store-defined
key is invisible. Neither present means nothing to authenticate, the same credential-is-the-gate
idiom the venues use; an absent control key brings the node up observe-only, while an absent
observe key means the daemon starts no node server at all. `NodeKeys` redacts its `Debug`, and
`NodeKeys::key_id` yields a stable `nk-` plus sixteen hex characters fingerprint an audit record
can name a credential by.
The `NodeKeys::key_for` doc records a claim that stood until 2026-08-29 — that verifying against an
absent key always fails. It is false: HMAC accepts a zero-length key, so a peer signing with the
empty key produces a tag that verifies. What closes the gate is `NodeKeys::has`, consulted before
the key; both servers refuse an unkeyed scope without reaching `verify` at all. The handshake is
also plaintext: it authenticates the connection, not each frame, and confidentiality still comes
from the tunnel or VPN in front of it.
## Versions and features [#versions-and-features]
`NODE_PROTO_VERSION` is 4 — v2 added bounded bar tails to the snapshot, v3 the server-authoritative
`Preview` dry-run, v4 an optional rationale beside a command. Because the version is folded into the
signed tag, a bump fails the handshake against every running node, so every verb since v4 rides
`Welcome.features` instead: `strategy-verbs`, `settings-show`, `settings-write`,
`strategy-mount-verbs`, and the one value-carrying entry, `datahub=`, which advertises the
data service's dial address so an operator configures one address and the client dials the data
plane itself — advertisement, never proxying. A client must check for the feature before sending the
verb, so against an older server nothing goes on the wire and the caller gets "this node predates
settings-write" rather than an undecodable-request error.
## The vocabulary [#the-vocabulary]
Requests after auth: `Subscribe { topics }` over `Topic::{Orders, Positions, Events, All}`,
`Snapshot`, `Command { cmd, reason }`, `Preview(cmd)`, `Ping`, `StrategyStatus`, `SettingsShow`.
Responses: `SnapshotFrame`, `Ack { coid }`, `Preview { accepted, reason }`, `Error`, `Pong`,
`StrategyStatus`, `SettingsShow`, and `SettingsWritten { restart_required }` — the one accepted
command that is not an `Ack`, because nothing entered the core; `false` means the node hot-applied
the value, and policy keys never are. A command's `reason` is remote free text landing in the audit
trail: the node sanitizes it, and it never reaches the order request, the core fold, the journal or
a venue.
`wire.rs` re-declares the field shapes of the core's snapshot and command types rather than
depending on `vike-core`, for two reasons its header gives: weight, since the live-runtime tree
would otherwise ride into the thin client, and a stable contract, since an internal refactor of
`CoreSnapshot` cannot then silently change the wire. `WireCommand` is deliberately the
session-relevant subset — `Submit`, `Cancel`, `Modify`, `MassCancel`, `Flatten`, `MarketExit`,
`SetTradingState`, `UpdateParams`, `MountStrategy`, `UnmountStrategy`, `SetSetting` — and not the
reconcile and journal plumbing (`ApplySnapshot`, `ReconcileReports`, `ConfirmRecon`), which is never
a remote client's business. `UpdateParams` and `MountStrategy` carry their parameter tables as the
core's own serde JSON rather than a hand mirror that would drift within a release. The server lowers
each variant into the core's real command at its edge and hands it to the
[control boundary](/docs/trader/concepts/control-boundary)'s `CommandSink`.
Two handles wrap all of this. `RemoteCoreHandle` authenticates as `Observe`, subscribes, and
exposes the latest pushed snapshot off a local arc-swap cell — the read side
[observe mode](/docs/trader/gui/observe-mode) runs on. `RemoteControlHandle` authenticates as `Control`,
never subscribes, and offers a non-blocking `try_command` over a worker thread whose ticket resolves
to that command's outcome. Both run the one crate-private handshake; a version mismatch names both
numbers, and an `AuthDenied` surfaces as `PermissionDenied` carrying the server's reason.
# Options (/docs/trader/concepts/options)
`vike-options` is the pure core under the [options chain window](/docs/trader/gui/options-chain):
egui-free, I/O-free, and dependent on no other crate in the workspace. Three of its modules are a
faithful `f64` port of the Python oracle's options package and are gated by the
[parity fixtures](/docs/trader/concepts/parity-gate); three more are additive, with no Python twin, and
are verified by finite differences and self-consistency instead. The crate's contract draws one
line firmly: venue-specific fetching and parsing lives in the deribit bridge's `chain.rs`, order
entry stays on the [execution client seam](/docs/trader/concepts/execution-client-seam), and an option's
identity stays in its instrument-name string — no strike, expiry or kind fields on the order
request.
## The chain model [#the-chain-model]
`model.rs` holds the normalized types a provider translates into and the UI consumes without
knowing which feed produced them: `OptionChain` for one expiry's snapshot, `Expiry` with its ISO
date, days to expiry and label, `StrikeRow` as a call and a put at one strike, and `OptionQuote`.
Quote fields are `Option` — absent stays absent, never `0.0` — and rows are ascending by
strike. Expiries settle at 08:00 UTC through `expiry_ms`. `make_expiry` computes days to expiry as a
calendar-day difference of UTC dates rather than a floored elapsed-millisecond count: late in the
UTC day the floor rounded both today and tomorrow to zero and the expiry strip rendered two `0DTE`
pills. `limit_strikes` windows a chain to `n` strikes below the spot and `n` at or above it, so a
±3 view shows exactly three rows on each side of the spot band.
## Price, greeks and implied vol [#price-greeks-and-implied-vol]
`greeks.rs` derives Δ, Γ, Θ and V uniformly from a feed's implied vol, because feeds give IV but
not full greeks. The conventions are European exercise, continuous compounding, no dividends, theta
per calendar day, vega per one vol-point, and a 365-day year. The risk-free rate `r` is a plain
parameter — the Python twin read it from the environment at import with a default of zero, and
here the read stays in the consuming binary; pass `0.0` to match. `black_scholes_price` and
`black_scholes_greeks` share one `d1_d2` site, and every transcendental — `erf`, `exp` and `log` —
comes from the `libm` crate rather than the platform: IEEE 754 requires `+ - * /` and `sqrt`
correctly rounded and requires nothing of `exp` and `ln`, so the MSVC runtime and glibc are each
entitled to a different last bit. `sqrt` deliberately stays on `f64`. The module records that it was
half-converted for a while — `libm::erf` two lines above an `x.ln()` — and states the lesson: a
half-converted file is worse than an unconverted one, because a nearby `libm::` spelling is exactly
the evidence a reader uses to decide not to look.
`implied_vol` inverts the price by bisection over sigma in `[1e-4, 5.0]`: 64 fixed steps with a
`1e-6` absolute early exit, ported verbatim because the iteration shape is part of the oracle
contract, and `None` when the price sits outside the no-arbitrage band. `enrich_quote` fills a
quote's greeks from its IV and returns it unchanged when they are not computable.
Pure-arithmetic sites — days to expiry, windowing, scaling, the non-theoretical columns, format
strings — gate exact, as hex bits. Outputs derived through `erf`, `exp` or `log` gate at ≤1e-12
relative against CPython-pinned bits. Neither tolerance is ever widened, and a pure-arithmetic site
is never reclassified to the relative tier. The constants are the frozen oracle, and if one ever
moves out of tier that is a stop to be argued, not a number to repaste.
## Second-order greeks [#second-order-greeks]
`second_order.rs` adds vanna, vomma, charm, veta and color on the same `d1`/`d2` and density
internals. It is not an oracle port — the Python twin stops at first order — so it is verified by
finite differences against the crate's own first-order greeks, ≤1e-6 relative for every greek,
calls and puts. Charm, veta and color are reported per calendar day as passage-of-time derivatives,
negative when the quantity decays as the clock runs, so veta reads negative for an at-the-money
option at `r = 0`; published tables mix that sign convention row by row, and the finite-difference
gate pins this one. Vanna and vomma are raw annualized sensitivities per 1.0 of sigma — divide by
100 for the per-point scale of the first-order vega. With no dividends the only kind-dependent terms
vanish, so every greek here is identical for calls and puts, and the equality is asserted in tests.
## Baskets [#baskets]
`basket.rs` is the multi-leg analytics: expiry payoff, break-evens, maximum profit and loss, net
greeks across both orders, and probability of profit and expected value under a lognormal terminal
law. A `Leg` carries a signed quantity — positive long, negative short — and a non-negative
per-unit premium, so `net_premium` is `Σ qty·premium` with positive meaning a debit paid.
`payoff_at` is P\&L at expiry per 1.0 of underlying; contract multipliers are the caller's business.
The domain is `S_T ∈ [0, ∞)`: a price cannot go below zero, so the downside is always bounded and
only the upward ray can be `PayoffBound::Unbounded`, decided by the slope beyond the highest strike.
Summation is a naive left fold in leg order, so `net_greeks` is bit-identical to summing the per-leg
calls in the same order; the expected-value integral is a fixed-grid composite Simpson, and the
whole module is deterministic — no RNG, no I/O, no clock.
## Realized vol and the IV–RV read [#realized-vol-and-the-ivrv-read]
`vol.rs` has no Python twin. `EwmaVol` is the RiskMetrics recursion with `λ = 0.94` for daily data,
seeding the variance at the first squared return rather than at zero; `RollingVol` is the sample
standard deviation over a window. Both stream `update(price)` / `value()` over log returns computed
with `libm::log`, ignore non-finite or non-positive prices so a bad tick cannot poison the fold, and
report per-period vol — annualize with `DAYS_PER_YEAR` (365, matching the greeks' basis; an
equities caller passes 252). `atm_iv` picks the strike nearest the forward `F = S·e^{rT}`, averaging
call and put IV where both are present; `iv_rv_spread` is the plain difference, positive when
implied trades rich. The module says its own materiality is low: nothing outside the file calls
these yet, and they were made portable before a consumer arrived because that is the cheap moment.
# Order lifecycle (/docs/trader/concepts/order-lifecycle)
An order in Vike Trader is a `ManagedOrder` — `crates/vike-exec/src/order.rs` — and its status is
a finite state machine. `ManagedOrder::apply` is the **only** mutator of order state: it looks the
transition up in a table, returns `InvalidOrderTransition` on an illegal edge, and accumulates
fills into `filled_qty` and a running volume-weighted `avg_fill_px`. The one other writer is
`ManagedOrder::resolve_pending_cancel`, documented as *the ONE restore*: the engine fold's
cancel-race guard calls it immediately before `apply` to recompute a `PendingCancel` order's
pre-cancel status from its own fill stream, and it is a no-op from every other state.
## The states [#the-states]
| state | meaning |
| --------------------------------------------------------- | --------------------------------------------------------------------- |
| `Initialized` | built locally, not yet sent |
| `Submitted` | sent; emitted by the Rust side synchronously at submit |
| `Accepted` | the venue acknowledged a resting order |
| `Triggered` | a conditional order's trigger condition was met |
| `PartiallyFilled` | some quantity executed, some resting |
| `PendingCancel` | a cancel is in flight |
| `Emulated` | a conditional order held client-side, for venues without a native one |
| `Released` | an emulated conditional released to the venue |
| `Liquidated` | a perpetual force-close — a live state, deliberately not a terminal |
| `Filled` · `Canceled` · `Rejected` · `Denied` · `Expired` | the five **terminal** states |
`Rejected` is the venue's refusal; `Denied` is the risk gate's veto, pre-venue.
`OrderStatus::is_terminal` names exactly those five, and once an order is in one of them any
further lifecycle event for it is an illegal transition — dropped idempotently at the engine's
fold, never applied. `Liquidated` is excluded from that set on purpose, yet `OrderStatus::is_live`
— the cancel-send gate, "is a cancel still worth sending?" — excludes it too; the two disagree on
that one state by design, and a test pins the disagreement.
## The transition table [#the-transition-table]
`order.rs`'s `transition` is the table, verbatim:
| event | allowed from | to |
| ---------------------- | ----------------------------------------------------------- | ----------------- |
| `OrderSubmitted` | `Initialized` | `Submitted` |
| `OrderAccepted` | `Submitted` | `Accepted` |
| `OrderRejected` | `Initialized`, `Submitted` | `Rejected` |
| `OrderDenied` | `Initialized` | `Denied` |
| `OrderTriggered` | `Accepted` | `Triggered` |
| `OrderPartiallyFilled` | `Accepted`, `Triggered`, `PartiallyFilled` | `PartiallyFilled` |
| `OrderFilled` | `Accepted`, `Triggered`, `PartiallyFilled` | `Filled` |
| `OrderCanceled` | `Accepted`, `Triggered`, `PartiallyFilled`, `PendingCancel` | `Canceled` |
| `OrderExpired` | `Accepted`, `Triggered`, `PartiallyFilled` | `Expired` |
| `OrderLiquidated` | `Accepted`, `Triggered`, `PartiallyFilled` | `Liquidated` |
Two edges look wrong and are not: `PartiallyFilled → Canceled` and `Accepted → Canceled` are both
allowed, because a venue may cancel the sibling leg of a one-cancels-other pair server-side while
that leg has already partly filled. Three events are not transitions at all. `OrderModified` is a
self-transition legal only in the modifiable set (`Accepted`, `Triggered`, `PartiallyFilled`): it
rewrites the resting quantity and the limit or trigger price — whichever the order actually rests
on — and leaves the status alone. `OrderCancelRejected` and `OrderModifyRejected` are
non-terminal advisories: the order stays live and keeps its terms, and they are legal only while
the request they refuse could have been in flight.
`CAN_RECEIVE_CANCEL` answers "may this order receive `OrderCanceled`?" and `is_live` answers "is a
cancel worth sending?", and they differ on `Submitted`: a cancel may be sent to a submitted order,
yet a venue-honoured cancel of it returns an illegal transition and is dropped — masked in practice
by in-order delivery, since the accept lands first. `CAN_RECEIVE_LIQUIDATION` exists because that
question was once answered in two places that disagreed: the table spelled the set inline while
the engine's liquidation picker selected by negation and admitted five states the FSM refuses, so
the pick landed on an order `apply` then rejected. Both pairs are pinned by tests, so a coincidence
of membership is observed rather than assumed.
## The three rules a venue may not break [#the-three-rules-a-venue-may-not-break]
The state machine is shared; what varies is the venue's decoder. Three rules pin what a decoder
must do, and they are the contract every bridge is held to:
1. **Emitter split.** The Rust side emits `OrderSubmitted` synchronously at submit. The venue side
emits `OrderAccepted`, `OrderRejected`, `OrderCanceled`, `OrderFilled` and
`OrderPartiallyFilled`.
2. **No silent vanish.** A dead venue path must synthesize a terminal `OrderRejected`. An order that
was submitted and never heard from again is a bug, not a state.
3. **Exactly one terminal.** The state machine must reach exactly one terminal state. Illegal or
out-of-order events — a late cancel replay after a fill, a duplicate fill on reconnect — are
dropped without corrupting the terminal already reached.
## The harness that holds them [#the-harness-that-holds-them]
The rules used to be verified ad hoc, per venue. They are now one scenario table run against every
covered venue's **real** `event_mapper` plus the shared execution plumbing:
`crates/vike-bridge-core/tests/bridge_conformance.rs`. The invariant oracle is the real state
machine — `vike_exec::ManagedOrder::apply`, the same code the live core folds through — wrapped by
`ContractOrder`, which mirrors the engine's thin per-order policy: the bare `Fill` goes to the
account side and never touches the FSM, the wrapping `OrderPartiallyFilled`/`OrderFilled` advance
it deduplicated by `trade_id`, and a terminal event that fails to apply while the order is still
live is counted as a genuine loss. Each venue plugs in only its venue-native frames and its
fixture-tested decoder; the scenarios, the folds and the assertions are shared.
| scenario | what it proves |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Lifecycle | submit → accept → partial fill → full fill reaches `Filled`, one terminal |
| TransportDeath | a dead venue path still synthesizes a terminal, through the exact seam that venue uses — the command actor or the ambiguous-submit resolver |
| CancelAfterClose | a late venue `OrderCanceled` replay and a late `OrderCancelRejected` after the order closed are both dropped without corrupting the terminal |
| ReconnectMidOrder | driven through the real shared pump with a scripted drop and reconnect that replays the mid-order partial: no duplicate terminal, no double-counted fill, no lost terminal |
`conformance_roster_is_exhaustive` checks the covered and deferred lists against
`vike_model::VENUES`, so a new bridge crate cannot join the roster and silently escape the harness
— it reddens until it is wired in or deferred with a written reason. The crypto venues with a
fixture-tested private-WS decoder run the cumulative-fill variant; the FX, CFD and equity venues
run a whole-fill variant, because their decoders have no partial-fill path at all. One native-SDK
venue has no wire: its fill and cancel envelopes are JSON a C++ shim builds, and its row says
explicitly what a green result does *not* say about the SDK behind it, which no CI machine can
compile. The harness prints the whole matrix, so no coverage fraction is written here.
## What comes next [#what-comes-next]
Each event the state machine consumes has its own page under
[events](/docs/trader/concepts/events/), generated from the `Event` enum so the list cannot drift from
the code. The [ExecutionClient seam](/docs/trader/concepts/execution-client-seam) is where those events
come from; [the account](/docs/trader/concepts/account) is where the bare fill goes instead; and the
[reconciliation](/docs/trader/concepts/reconciliation/) pages are what happens when the venue's view and
this state machine's view disagree.
# The paper exchange (/docs/trader/concepts/paper-exchange)
When a venue is mounted paper — the default under both [gates](/docs/trader/concepts/risk-and-arming),
an unset arming ceiling and absent credentials alike — the
[ExecutionClient](/docs/trader/concepts/execution-client-seam) behind the engine is
`vike_paper::PaperExecutionClient`. It is not a stub that acknowledges orders. Its own doc states
what it does: `submit` publishes `OrderSubmitted` and `OrderAccepted` and rests the order; `on_bar`,
driven by the core on each closed bar of the mounted series, fills the book with the engine's exact
semantics — next-open market fills, limit and stop triggers, adverse-first intrabar resolution,
slippage plus maker/taker fees. What it fills them *with* is the point of this page.
## Built from the engine's own primitives [#built-from-the-engines-own-primitives]
`crates/vike-paper/src/lib.rs`'s module header states the design: the paper client owns a resting
book and fills it with the **engine's own** public fill primitives —
`vike_fills::fill_model::BarFillModel::fill_price`,
`vike_fills::fill_resolution::resolve_intrabar_fills`, `vike_fills::broker_sim::adverse_fill_price`
and `vike_fills::broker_sim::fee`. `crates/vike-fills/src/lib.rs` says why those live where they do:
the paper exchange and the backtest engine are the *same cost model seen from two sides*, and a
paper client mounted on a live feed must charge exactly what `SimBroker` charges or
"backtest == paper == live" stops being checkable. There is one definition of what a fill costs
and when it happens, and both sides call it — so `backtest == paper` holds by construction rather
than by two implementations happening to agree. See
[fill models](/docs/trader/backtesting/fill-models), [fill resolution](/docs/trader/backtesting/fill-resolution)
and [the cost model](/docs/trader/backtesting/cost-model) for the primitives themselves.
That is possible because of the [layering rule](/docs/trader/concepts/architecture) this workspace keeps
re-learning: when two sides must not disagree, the cure is a shared crate **below** both. The paper
client's header carries the history: it was folded *into* the backtest crate precisely to guarantee
one fill definition, when co-location was the only way to guarantee it; once `vike-fills` existed,
the two could share a dependency instead of a crate, and `vike-paper` split back out. The payoff
is on the consumer side — `vike-mount` and `vike-run` had referenced the backtest crate for this
client and nothing else (12 and 4 sites), compiling the entire simulator to obtain an
`ExecutionClient`.
## The gate that proves it [#the-gate-that-proves-it]
`crates/vike-backtest/tests/r7_gate.rs` takes the same strategy and the same bars, runs them through
the backtest engine and through the live core in paper mode, and requires **bit-identical** fills
— timestamp, side, quantity, price, fee, maker flag — plus identical realized PnL, fees and final
position, every float compared through `to_bits`. The strategy logic is defined once, as a shared
signal function, and mounted through each runtime's own trait; so the gate proves the *runtimes*
agree, not that two copies of a strategy happen to. The gate stays in the backtest crate rather than
beside the client because it drives the engine *and* the live core, not the client alone.
It proves the conditional verbs too. The stop-verb row agrees when the engine is configured with
`EngineParams::emulator_release_stops = true` — the fired conditional releases as a market order
and fills at the next bar's open, which is what live always does through its emulated
`ConditionalBook`. The bracket row — an entry with a protective stop and a resting take-profit —
agrees through the paper book's one-triggers-other / one-cancels-other triple: both stops fill via
the one trigger oracle, and both sides cancel the surviving exit. The resolver those brackets share
is [contingent orders](/docs/trader/concepts/contingency).
The frozen golden fixtures prove the Rust backtest has not changed its arithmetic unnoticed; this
gate proves the Rust backtest equals the Rust paper core. Together, the paper mode a strategy is
first run against is the same arithmetic its backtest report was computed with — see
[the parity gate](/docs/trader/concepts/parity-gate).
## Where the equivalence stops, stated rather than implied [#where-the-equivalence-stops-stated-rather-than-implied]
The header scopes the law precisely. It holds for `Gtc`, `Ioc` and `Fok` — everything both sides
treat as "rests until filled or cancelled". It does **not** hold for the expiring time-in-forces,
`Gtd` and `Day`: the paper client enforces their resting deadline (`expire_due`), while
`StrategyEngine`/`SimBroker` ignore time-in-force entirely, so for such an order the engine keeps
resting and filling where paper expires. Nothing gates that divergence — the gate only exercises
`Gtc` — and nothing in the tree produces a non-`Gtc` order into either side today, so it is
unreachable from a strategy; the header names mirroring the sweep in `StrategyEngine::fill_pending`
as the follow-up that would close it. Two details of the paper side's own expiry are worth knowing
before reading a paper fill: `Ioc`/`Fok` are *not* enforced there and rest exactly like `Gtc`
(they are immediate-execution semantics, a change to the parity-gated fill pass, not a resting
deadline), and a `Day` order is scoped to the **UTC calendar day of the first bar it sees**, never
to the request's own timestamp, because nothing on the write path stamps one.
One more edge. The engine's *default* stop semantics — a same-bar fill at the trigger —
deliberately diverge from live, where a stop is always emulated and releases as a market order.
That divergence is pinned by its own law test in the backtester (`tests/laws/trigger_law.rs`), not
by this gate; the gate proves agreement under the configuration that matches live.
`crates/vike-ops/tests/paper_mount_arming_gate.rs` classifies every file that constructs a
`PaperExecutionClient`: a mount seam must arm the operator [halt](/docs/trader/concepts/halt) sentinel,
and a simulation must not. The second half is not symmetry for its own sake — the r7 gate drives
this same client, so a sentinel read on a simulation path would make backtest fills depend on a
file lying around in `/settings/state/` and redden the equivalence gate for a reason
nobody would find. The gate exists because a documented "each mount seam asserts on this" claim
was already false when it was written: a third seam existed, unarmed, and per-seam assertions are
evidence about the seams that have them, never about the set.
# The parity gate (/docs/trader/concepts/parity-gate)
Vike Trader's pure cores — the fill primitive, the fill triggers, the consolidators, the cost
scalars, the event engine, the vectorized kernel, the sizing math, the account read-model — are
held to **bit-identical** `f64` results against committed fixtures. Not "close enough", not a
relative tolerance: the same IEEE-754 bit pattern. This page is what that means, why it is still
true after the thing the fixtures came from was retired, and the one rule that must survive any
future relaxation.
## The fixtures are frozen, and that is why the comparisons stay exact [#the-fixtures-are-frozen-and-that-is-why-the-comparisons-stay-exact]
`fixtures/r0..r6/` were exported from an earlier implementation of the platform; each carries a
`manifest.source_sha` pinning the commit that exported it. Every exporter was later deleted from
the tree, so — as `crates/vike-backtest/tests/parity.rs`'s header puts it — **the committed bytes
ARE the oracle**: there is no environment to regenerate from, no network, no external process, and
nothing to drift. Floats are stored as hex bit patterns and read back through
`vike_model::f64_from_hex_bits`; the `r1_parity`/`r3_parity`/`r4_parity` golden gates in that
binary compare them exactly. (Those three described themselves as "parity vs the Python oracle"
until 2026-08-28; the provenance is unchanged, the ongoing-comparison reading was never true after
the exporters went.)
What those gates prove is precise and worth stating precisely: that **this** code has not changed
its arithmetic unnoticed. They do not prove ongoing agreement with anything external, because
nothing external is run. The [decision record](/docs/trader/developer/decision-records) that retired the
oracle is explicit about the half that matters most:
Every committed golden fixture keeps its exact comparison. The rule that dies is "a new port must
reproduce the old implementation bit-for-bit" — new work needs no external twin. The rule that
**survives** is "never widen a tolerance to make a failing test pass; investigate the divergence."
It was never really about the old implementation. It is about a number that moved for a reason
nobody has named yet, and it applies to every gate in the tree. Relaxing an existing assertion
needs its own argument: name the mechanism, keep the comparison exact against a *stated function*
of the reference rather than a tolerance band, bound the raw reference value too, and add a
control that fails if the relaxation turns out to be a no-op.
## The hardest-won detail: `sum()` is not a fold [#the-hardest-won-detail-sum-is-not-a-fold]
`crates/vike-model/src/pysum.rs` records the single most instructive divergence the gates ever
caught: CPython 3.12 and later compute the builtin `sum()` over floats with **Neumaier compensated
summation**, not a naive left fold. A ported site that the original wrote as `sum(...)` must
therefore use `vike_model::py_sum`, while an explicit `acc += x` loop stays a naive fold — mirrored
per site. Getting it wrong is a guaranteed last-ulp divergence, caught by the bit-gates on
2026-07-03. The lesson generalises: bit parity is a property of *operation order*.
`crates/vike-model/src/fill.rs`'s `compute_fill` says it outright — do not reorder operations,
introduce `mul_add`, or "simplify" — and `vike-fills` carries the same rule for the cost scalars.
Even container choice is part of it: `crates/vike-exec/src/account.rs` keeps every map an
`IndexMap`, **never a `HashMap`**, because insertion order *is* the `f64` fold order the gate pins
against `fixtures/r0/account_scenarios.json`.
## Why the exactness is portable [#why-the-exactness-is-portable]
A pinned constant usually survives a platform change by luck — two platform maths libraries
agreeing on the inputs it happened to sample. `parity.rs`'s header argues that hazard does not
reach these gates, by construction: every module they drive reaches no transcendental function and
no integer power, only `+ - * /`, which IEEE 754 requires to be correctly rounded. It tells the
reader to grep those modules for a libm-class call before trusting the paragraph, because
introducing one is what would make the fixtures platform-sensitive and nothing else in the suite
would say so. Elsewhere in the tree, where transcendentals *are* needed, a separate decision record
routes them through the `libm` crate rather than the platform — the property bought is
**reproducibility, not accuracy**; the two platforms are differently wrong at the last bit, and
the change makes them identically wrong everywhere.
## The second oracle, and the third [#the-second-oracle-and-the-third]
Golden fixtures only pin examples. `engine_kernel_parity`, in the same binary, runs the event
engine (`StrategyEngine`) and the vectorized kernel (`VectorBacktestEngine`) on identical synthetic
bars and requires the same trade count and a **bit-identical final equity** — both are naive-fold
`f64` paths over the same inputs, and its own header says never to widen it to a tolerance. It is
the workspace's second oracle, and it keeps working as strategies are added because it compares two
things that both run rather than one thing against a frozen file. `rhai_parity` is the third: a
Rhai script and its Rust twin must produce a byte-identical `BacktestResult`.
The [paper exchange](/docs/trader/concepts/paper-exchange) carries the next link in the chain: the
backtest engine equals the live paper core, bit-for-bit, under `r7_gate`. And the
[indicator](/docs/trader/indicators/) crate holds a parity of its own —
`crates/vike-indicators/tests/parity.rs` proves every registered indicator's streaming `on_bar`
fold equals its batch `vectorize`, bitwise, with both-NaN counted equal; the three `batch_only`
indicators that read future bars (`ichimoku`, `zigzag`, `williams_fractal`) are skipped by name,
their output shape still gated.
## Committing a counterexample [#committing-a-counterexample]
Property tests randomize the pure cores where fixtures only pin examples —
[property tests](/docs/trader/developer/testing/property-tests) lists the suites. When one finds a
counterexample in CI, the generated regression seed is **committed**, so the failure reproduces on
every machine rather than depending on the random draw that found it. Adding a fixture, a property
suite or a seed is the procedure on [adding to the parity suite](/docs/trader/developer/testing/parity).
# Portfolio and snapshot (/docs/trader/concepts/portfolio-and-snapshot)
`crates/vike-core/src/snapshot.rs`'s `CoreSnapshot` is the immutable state view the core
publishes to the window through arc-swap. The GUI is a lossy downstream observer: it reads the
latest snapshot on repaint and can never back-pressure the core.
`crates/vike-core/src/portfolio.rs`'s `Portfolio` is the named cross-venue aggregator inside it,
and `crates/vike-core/src/runtime/publish.rs` is the publish. This page is written from those
three.
## When a snapshot is built [#when-a-snapshot-is-built]
Snapshots are built coalesced, never per event while busy: dirty-flagged, at most once per
`CoreConfig::snapshot_interval` while the queue is busy — the interval is a minimum spacing, not a
deadline — and immediately whenever the core is about to go idle — `if self.dirty` directly before
`blocking_recv`. On a venue whose events arrive sporadically that idle path publishes per event,
which is why anything `CoreSnapshot::build` does is per-event work; the
[execution core](/docs/trader/concepts/execution-core) page carries that consequence. `seq`
increments per publish for change detection, and the build cost is measured in the latency
harness.
`publish_guarded` wraps the build and the GUI's repaint hook in `catch_unwind`: neither may kill
the core thread. On a panic the engine is halted and a minimal fault snapshot is stored —
`trading_state: Halted`, `fault` set — so the window still sees terminal state, never a stale
active cell. The recent-events ring holds already-rendered lines, so publish is a refcount bump
per entry, and an opt-in memory-mapped counter mirror is written at this same cadence.
## What the snapshot carries [#what-the-snapshot-carries]
Beyond the primary engine's `trading_state`, `balance` and `balance_mode`, a snapshot holds
`portfolio`; `positions` and `marks`; `orders`, the registry in insertion order across every
engine; `held_exits`, the pending bracket exits held off the venue until their OTO parent fills,
which have no registered order and would otherwise be invisible; the core-owned `bars` cache,
shared by pointer; `mounts`; `recent_events`; `fault`; `conflated_market_drops` (latest-wins
working, not a bug) and `rejected_commands` (never silent); the `recon` block of held
divergences awaiting an operator confirm plus the last pass timestamp; and `recon_coin_deltas`,
populated only by a venue whose position report carries a delta — Deribit today — so the
greeks tool can fold a perp or future hedge leg.
Each `PositionView` carries the resolver-priced `unrealized` and the `mark_source` that priced
it (`None` means unpriceable), the effective `leverage`, an estimated `liq_price`, the
`margin_mode` and any `isolated_margin`. Each `VenueBlock` — one per engine, primary first —
carries balance, realized PnL, fees, funding, `balance_mode`, resolver-priced `equity` and
`unrealized`, the `missing_prices` count, `margin_used`, `free_bp` and `margin_ratio` (computed
only when the margin gate is armed, so the default path stays zero-cost), the effective
`fee_schedule`, the venue's `trading_state`, and the contract-multiplier grid shared with the
engine's account by `Arc` — the whole grid, because the window needs a multiplier for symbols
it holds no position in. `CoreSnapshot::build` does one resolve per engine through the
[price board](/docs/trader/concepts/price-board), so a block's equity, its unrealized total, its
missing count and every position's mark source come from the same read.
## The mount rows and the residual [#the-mount-rows-and-the-residual]
`mounts` holds one `MountView` per live strategy mount — venue, symbol, interval, `ready`, and
the per-mount attribution: net position, realized PnL net of fees, unrealized PnL, notional, any
budget and whether it latched. When any mount exists the vector carries one trailing row,
`MountRowKind::Residual`: the account's realized PnL net of fees minus the sum of every mount
row's. Attribution is keyed by client order id, an un-minted id is simply not attributed, and
nothing used to sum the two sides — so the mount rows and the account disagreed systematically
and silently. With the row, `Σ mount rows + residual == account realized (net)` is an invariant
anything can assert, and manual tickets, margin-call and budget-latch liquidations,
reconcile-adopted orders and fills whose mount could not be reconstructed across a restart all
land there visibly. Read `kind` before treating a row as a mount.
## The portfolio [#the-portfolio]
`Portfolio` groups what used to be loose scalars: `equity` (the primary engine,
resolver-priced), `equity_total`, `realized_pnl`, `fees_paid`, `funding_paid`,
`margin_used_total`, `capital_base`, `missing_prices_total`, the per-asset
`balances_by_asset` ledger, and the `venues` blocks. It is a naming reshape — no value changed —
with a read-only query surface over the blocks: `venue(name)`, `equity(venue)`,
`unrealized_pnl(venue)`, `net_position`, `gross_position`, `is_net_long`, `is_flat`,
`position_count` (rows, not open positions), and `missing_price_instruments`, which names the
`(venue, symbol)` pairs the count only counts. None of it runs on the fold.
A `VenueBlock`'s `equity` answers a different question depending on its `balance_mode`. A
`Delta` block is the daemon's own book-keeping from a configured seed — `seed + own cash flow +
realized + unrealized` — an assertion about this mount's trading, which is why it can be summed
across venues. An `Authoritative` block is the venue's attested wallet for the whole account the
credentials open, adopted verbatim by reconcile or an account-state push, and the daemon cannot
tell whether that account is dedicated to it: cash is fetched account-wide while positions and
fills are symbol-scoped. Measured on prod2 on 2026-08-17: nine paper mounts at 1000 seed each
plus one bybit mount that had adopted a shared account's 53647 USDT wallet summed to a headline
`equity_total` of 62647.10600813 — neither the account's cash nor the daemon's book. The value
is unchanged; the honest split sits beside it as `equity_book_total` and `equity_wallet_total`,
which partition it by mode, and `wallet_venues` names whose wallets were added. They do not sum
back to `equity_total` bit-for-bit, because each is its own compensated sum over a subsequence.
`realized_pnl`, `fees_paid` and `funding_paid` on the portfolio are bound from the primary
engine's account, so on a multi-venue core they answer about whichever venue is registered
first. Measured on prod2: the daemon's summary line read `realized_pnl: 0.0` from an untraded
binance primary while `equity_total` tracked a bybit mount's ten maker fills to eight decimals.
A report that carries `equity_total` must fold `realized_pnl_total`, `fees_paid_total` and
`funding_paid_total` — the same compensated sum over the same vector in the same order.
## The risk curve [#the-risk-curve]
`capital_base` is the operator's configured capital, `Σ seed_cash` in venue registration order:
a config constant, not an observation, identical across a restart and untouchable by a third
party moving money in a shared account. A portfolio built off the node wire reports `0.0`
there, which is honest — the observe path has no seeds and drives no alerting — and `0.0`
means unknown, never "no capital". `pnl_total` is the daemon's own profit and loss across every
venue, `realized − fees + funding + unrealized`, mode-blind and carrying no balance level, so a
wallet movement with no trading behind it does not move it. `drawdown_curve` is
`capital_base + pnl_total`: the quantity the [watchdog](/docs/trader/concepts/watchdog)'s drawdown
latch folds its high-water mark on and the [alerting](/docs/trader/concepts/alerting) drawdown rule
compares against, pinned bit-identical between the two. Use it, not `equity_total`, for any
drawdown fraction — and guard a non-positive value rather than divide by it.
# The price board (/docs/trader/concepts/price-board)
`crates/vike-exec/src/price_board.rs`'s `PriceBoard` is where every price the core hears is
kept, tagged by where it came from. Its write side sits on the hot fold and does nothing but
store; its read side runs only on cold paths — snapshot publish, sweeps, timers — and answers
one question: what is this position worth to me right now, if I had to act on it. The board is
deliberately not serialized: it sits outside `EngineSnapshot`, so the
[journal](/docs/trader/concepts/journal/)'s determinism-fence hash is untouched. This page is
written from that module and from `crates/vike-exec/src/account.rs`'s `MarkSource`.
## The cell [#the-cell]
A `PriceCell` holds five optional `(price, timestamp)` slots: `mark`, `bid`, `ask`,
`last_trade`, `bar_close`. The timestamp is the producer's where the lane carries one, else the
dispatch clock. Every write is unconditional and source-tagged by construction, so a candle
close and a venue mark both land without either destroying the other — which is why no
precedence question arises on the write side. A zero, negative or `NaN` price is ignored and
creates no slot; a quote with one dead side stores only the live side. Cells are keyed by
`(venue, symbol)` in insertion order.
## The valuation law [#the-valuation-law]
`PriceBoard::classify` walks the slots in a fixed order and returns at the first fresh one. The
ordering *is* the law — a rung is consulted only when every rung above it is absent or stale:
| rung | slot | meaning | who fills it |
| ---- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `mark` | the venue's own mark or index price — what its liquidation and funding engines key off; the only price that answers "will I be liquidated" | the crypto perps' mark streams (binance-family `@markPrice@1s`, bybit `tickers.markPrice`, okx `mark-price`, hyperliquid `activeAssetCtx.markPx`), each on by default; aster's is off until `VIKE_MARK_STREAMS_ASTER=1`, and `VIKE_MARK_STREAMS=0` turns them all off. On any venue, also a fill's `mark_price` and a reconcile pass's `position_mark_px` |
| 2 | `bid` / `ask` | the live top of book, read side-aware: a long at the bid, a short at the ask — the side it would have to cross to exit, so the spread is booked against the position, never for it | any venue with a quote or L2 feed (`set_quote`) |
| 3 | `last_trade` | the last price something actually traded at — real and recent, but one-sided and already stale when it lands | any venue with a trade feed, including one subscribed only because a chart asked |
| 4 | `bar_close` | the close of the last completed candle — a minute old on a 1m chart, an hour on a 1h one | every kline feed, every venue |
| 5 | last-known | not a slot: the same four rungs walked again with the freshness gate relaxed, taking the highest-priority slot holding any value however stale | the freshest surviving slot |
`resolve` is a thin projection of `classify`: `Fresh` becomes `Resolution::Priced { px, source,
ts }`, `Missing` is a first-class answer, and rung 5 becomes `Resolution::Stale` only when the
caller opted into `PriceCfg::stale_fallback`, so a consumer valuing off it *knows* it is stale.
`classify` itself is independent of that knob and always reports fresh, stale or missing, so an
operator query can see "priced but stale" even when valuation will not fall back to it.
Every `PriceCfg` max-age field defaults to `None` and `stale_fallback` to `false`, so a caller
that has not opted into a window sees the pure priority order, nothing is ever stale, and rung 5
is unreachable. A fresh venue mark early-returns at rung 1, so the common case pays for one
slot check. `use_mark: false` drops the mark rung from both walks.
## Why the candle close is last [#why-the-candle-close-is-last]
Before the mark-slot split, every kline feed wrote its close into the `mark` slot, so a stale
candle outranked a live quote and a live trade on the same symbol. Putting the close at the tail
is a real, intended behaviour change on every venue that has no venue mark but does have a
quote or trade feed running — alpaca, IBKR, binance spot, an aster perp not opted into its mark
stream, any perp under `VIKE_MARK_STREAMS=0`: those positions are now valued at the live
side-aware quote or the last trade instead of the candle close. Valuation moves by up to the
spread, toward the price the position could actually be exited at.
## Missing prices [#missing-prices]
`PriceBoard::note` keeps a per-venue set of symbols whose resolution came back `Missing`. The
first insertion of an episode warns once — *no price for open position — subscribe to quotes,
trades or bars for continuous mark-to-market* — and a `Priced` or `Stale` resolution removes
the symbol, re-arming the warning for the next episode. The equity sampler (see
[timers](/docs/trader/concepts/timers)) notes every open position once per sample; the snapshot
surfaces the count as `VenueBlock::missing_prices` and each position's `mark_source: None`, and
`Portfolio::missing_price_instruments` names the pairs.
## The board is not the account mark slot [#the-board-is-not-the-account-mark-slot]
`Account.marks` is the opposite shape: one untagged scalar per symbol, written by the same lanes
and read by the pre-trade gate, the [margin-call](/docs/trader/concepts/margin-call) law and
`LiveBroker.price`. Its precedence rule lives in `vike_exec::MarkSource` and is enforced inside
`Account::set_mark_from`, the only door into that map. A genuine venue mark — streamed
(`VenueMark`) or reported by a reconcile snapshot (`ReconcileMark`, ranked equal) — owns the
slot for a window: `DEFAULT_MARK_STALENESS_MS` is 10 000 ms, about ten missed ticks of a 1s
stream, and `DEFAULT_RECONCILE_STALENESS_MS` is 150 000 ms, 2.5× the default reconcile
interval, so a reconciled stream-less venue's slot stays owned continuously between passes
rather than alternating with candle closes every minute. A mark stream that dies hands the slot
back after its window and closes resume, so valuation degrades to the previous rung instead of
freezing at a stale mark. The module doc is explicit that the board must not reimplement that
rule: a freshness predicate on the board is how an earlier round of the fix ended up bypassable
by every lane that did not happen to call it.
## Who reads it [#who-reads-it]
`ExecutionEngine::resolve_equity` builds a whole `ResolvedEquity` — equity, unrealized total,
the missing count and one `ResolvedPosition` per position — from one resolve per engine, so the
[snapshot](/docs/trader/concepts/portfolio-and-snapshot)'s `VenueBlock` and every `PositionView`
come from the same read. Its scalar twin `resolved_equity` is the decision-path entry point:
the liquidation watchdog, the drawdown latch, the armed margin gate, a strategy's `ctx.equity`
and the portfolio-snap journal all read equity through it, so an auto-liquidation acts on
exactly the number the operator sees. The readiness gate on a
[mount](/docs/trader/concepts/mounting) flips a pending mount to ready the moment either side of its
symbol resolves through its own engine's board.
# Risk and arming (/docs/trader/concepts/risk-and-arming)
Nothing in Vike Trader reaches a real venue by accident. Between an order and the wire there are
four checks, and the order they run in is the point. The first two decide whether a venue is
*mounted* live at all; the third runs on every order after that; the fourth is the operator's
stop. This page is written from `crates/vike-config/src/venue_mode.rs`,
`crates/vike-mount/src/lib.rs`, `crates/vike-exec/src/risk.rs` and `crates/vike-exec/src/halt.rs`,
each the authority for its own gate.
## Gate 1 — the arming ceiling, read before any credential [#gate-1--the-arming-ceiling-read-before-any-credential]
`/settings/policy.toml` carries a `[venues]` table:
```toml
[venues]
binance = "demo"
bybit = "live"
```
Each value is a `vike_config::VenueMode` — `paper` \< `demo` \< `live`, an ordering encoded in the
type's own variant declaration — and it is a **ceiling**. `vike_mount::make_engine`, the one entry
point that builds a venue's execution client, consults it **first**, above the credential read: a
`paper` venue returns the paper client having loaded no credential, fetched no instrument grid and
opened no socket.
The ceiling can only ever refuse. `VenueMode::cap` is `min`, never `max`, and it is a named method
rather than a `.min()` at each call site for one reason: `a.max(b)` is one character from
`a.min(b)` and reads just as plausible, while the two differ by "the file can only refuse" versus
"the file can arm real money".
```rust
// A `live` ceiling over a demo-credentialled venue stays DEMO — the ceiling never promotes.
assert_eq!(VenueMode::Live.cap(VenueMode::Demo), VenueMode::Demo);
// A `paper` ceiling over a live-credentialled venue REFUSES it down to paper.
assert_eq!(VenueMode::Paper.cap(VenueMode::Live), VenueMode::Paper);
```
So `bybit = "live"` does not put bybit live. It declines to stop bybit going live if its
credentials and its mainnet flag already say so. The measured defect it closes: a daemon whose run
profile mounted one venue logged nine authenticated live sessions, none of them asked for, all a
consequence of credential pairs sitting in one file.
The default is a filled map, one `paper` per roster venue — including venues this build cannot
mount, because a `policy.toml` must be portable between a GUI box and a daemon. It is the one
settings default in the workspace that changes behaviour, and it is deliberate: absence of the
file must be the safest answer. Because "everything at paper" and "nobody wrote the table" produce
the same map, `VenuePolicy::is_declared` records whether a file ever named a venue, and
`vike_mount::venue_arming_migration` warns once at startup — naming the file, the key and the
venues it refused — rather than refusing to start.
The ceiling is also the only refusal that exists for a venue with no other switch: Aster's mount
tries its live keys first and has no mainnet flag to withhold, which is the measured hole this gate
was built to close. A second account on a venue gets its own line under `[accounts]`, and a
labelled account with no line resolves to `paper` — the [accounts](/docs/trader/concepts/accounts) page
carries that grammar. Every key on this page is documented on the
[policy settings reference](/docs/trader/reference/settings/policy).
## Gate 2 — absent credentials are the live gate [#gate-2--absent-credentials-are-the-live-gate]
Only once the ceiling allows it does the older rule apply. A venue whose `{VENUE}_DEMO_*` keys are
present in the credential store spawns a real, credential-gated execution client; otherwise it
stays paper — `vike_paper::PaperExecutionClient`, a resting book behind the same seam. Three
refinements sit on this gate, each at the venue that needs it:
* **Mainnet is an explicit flag.** `{VENUE}_MAINNET` is the exact string `"1"`, read from the
process environment or the store with the process winning. For the three CEX venues
`make_engine` resolves it exactly once per mount (`cex_mainnet_enabled`) and threads a `bool`
into the grid pre-fetch, the exec spawn and the reconcile client, so no spawned thread re-reads
global state and a mount can never sign mainnet credentials against demo hosts.
* **A withdraw-capable key is refused.** The binance live arm consults
`vike_bridge_core::key_permissions` before arming (`arming::binance_withdraw_gate`), so a key
known to carry withdraw permission degrades that venue to paper instead of being discovered at
the first order.
* **A venue with no testnet is gated twice.** Polymarket's only tier is real money, so
`POLY_EXEC=1` mounts its execution client and `POLY_RECONCILE=1` independently mounts its
reconcile client; both are unset by default.
A live mount also refuses to start without an account-dependent risk budget:
`MountError::MissingRiskBudget` names every missing `[risk]` key, because no universal safe default
exists for `max_notional_per_order` or `max_total_exposure`. A paper mount never sees it. The
[startup preflight](/docs/trader/concepts/startup-preflight) is the go/no-go probe that runs once after
mounting.
## Gate 3 — the RiskGate, on every order [#gate-3--the-riskgate-on-every-order]
`vike_exec::RiskGate` is one extensible stage every order crosses. It is **pure**: it owns no bus
and publishes nothing; the engine acts on its verdict, which is what lets the same rules run
identically in backtest, paper and live — a veto is an `OrderDenied` event, terminal and
pre-venue, in every mode.
Its limits are built at mount from the venue's own fetched `SymbolProperties`
(`RiskLimits::from_properties` — tick, lot and the two floors; a `0.0` field means unconstrained)
and from the run profile's `[risk]` table:
| limit | what it does |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `tick_size`, `lot_size` | round the order onto the venue grid, round-half-to-even |
| `min_qty`, `min_notional` | floors — on **opening** orders only; a pure reduce is exempt, so a position can always be closed |
| `max_notional_per_order` | the per-order notional ceiling. It lives in `policy.toml`, and `Policy` implements neither the env nor the CLI override trait, so raising it from the environment is a compile error rather than a review comment |
| `max_total_exposure` | a cap on projected open notional — see the warning below |
| `max_orders_per_window`, `window_ms` | a submission-rate throttle |
| `max_leverage` | the operator's declared cap, recorded for audit; enforcement is `im_requirement`, derived as `1 / max_leverage` so the two cannot disagree |
| `price_collar` | the fat-finger axis: an opening order whose limit or trigger price sits further from the mark than `max(pct × mark, abs_floor)` is denied. Both halves are required — a percentage is useless on a cheap instrument, a floor on an expensive one |
| `max_slippage_bps`, `require_fillable` | an opt-in walk-the-book veto; inert until a caller holds both the intent and a live book |
Notional includes the contract multiplier, exactly as the backtest fill gate computes it, so live
and backtest verdicts agree. A multi-leg combo crosses the gate as one atomic batch — all legs
pass or the whole combo yields a single denial naming the failing leg — and consumes one throttle
slot, because it is one venue order. A modify is judged against the *projected* order, the
resting request with the new terms folded in, never against the delta; its veto is non-terminal
and consumes no slot.
It is not account-wide, cross-symbol or cross-venue. The gate holds no cross-symbol position book,
and `make_engine` builds one engine — so one gate, so one copy of this cap — per venue. An
operator trading N symbols is protected by this number N times over, once each, and never once in
aggregate: a value sized for a whole book is an N× weaker cap than they believe they set.
`crates/vike-exec/tests/risk/risk_lane_completion.rs`'s
`max_total_exposure_is_scoped_to_one_venue_and_one_symbol` pins the scope. The name is kept
because it is also the operator-facing TOML key, the string in the live-mount refusal, and a serde
key inside the journal's state hash.
## Gate 4 — the halt, and why it is deliberately strict [#gate-4--the-halt-and-why-it-is-deliberately-strict]
A manual halt — and the two [dead-man's switches](/docs/trader/concepts/dead-mans-switch) that engage one
automatically, one on a link the venue's bridge reports dead and one on a feed that has gone
silent — must stop opening risk without trapping anyone in a position. Two
mechanisms agree on that policy and differ in verification strength. The `RiskGate` under
`TradingState::Halted` admits only a position-verified reduce (`vike_model::is_covered_reduce`:
the order must oppose the position and be covered by it), because it holds the position book. The
`ExecutionClient` boundary does not, so `vike_exec::halt::halt_admits_submit` takes the caller's
`reduce_only` flag as the only evidence in existence. That is the default `HaltAdmit::Admit` mode,
in which a venue's own position report is ignored outright; the tighter `HaltAdmit::Verify` mode
still requires `reduce_only` and additionally refuses a submit the venue's book *proves* opens
risk — and there every unknown admits (never fetched, fetch failed, an answer for another
account), because the promise being kept is *halting cannot trap you*, not *halting is airtight*.
The [halt](/docs/trader/concepts/halt) page carries the per-venue admit table.
# Settings (/docs/trader/concepts/settings)
Nearly every setting Vike Trader reads lives under one directory, `/settings/`, in five
files whose names say what kind of value each holds — a run profile's `[risk]` table, under
`/user_data/profiles/`, sits outside it, and a live mount refuses to start without the two
caps it supplies:
```
/settings/
├── secrets.env credentials — the one store, nothing else can supply a key
├── policy.toml hard ceilings — file only, no environment, no command line
├── config.toml deployment wiring — store paths, log directory, listen addresses
├── preferences.toml taste and tuning — log levels, chart style, sweep threads
└── flags.toml operator toggles, each with an owner and a review date
```
The split is by **authority** — who is allowed to change a value and from where — not by
subsystem. `crates/vike-config/src/lib.rs` is the typed model and layered loader for the four TOML
files; `crates/vike-secrets/src/lib.rs` owns the credential store. The decision record behind the
shape opens with the problem it solved: settings had lived in eight mechanisms with no root and no
precedence order, and `max_leverage` was exactly as env-overridable as a chart colour.
## Policy has no environment layer, by construction [#policy-has-no-environment-layer-by-construction]
The precedence is one line and applies to everything: code defaults, then the file, then the
environment, then the command line. What differs per type is *which layers exist*. `Policy` —
leverage, per-order notional, the market-slippage band, halt admission, and the per-venue
[arming ceiling](/docs/trader/concepts/risk-and-arming) — is the one settings type that implements
neither `EnvOverride` nor `CliOverride`. `crates/vike-config/src/layers.rs` seals both traits, so
`settings.policy.apply_env(&env)` is not a discouraged call but a compile error, and a downstream
crate cannot bolt the impl on from outside. The command-line layer has no policy field either,
because arguments leak into process listings, shell history and CI logs. Anyone who can set an
environment variable on a box — a shell export, a stale service-unit line, an inherited parent
process — can no longer widen a live risk limit with no file changed and no diff.
When the per-order notional ceiling moved from two environment variables into
`policy.max_notional_per_order`, deleting the reads was only half the job: an operator who still
set the old variable believed a ceiling was armed, and a build that quietly ignored it would trade
uncapped while they believed otherwise. So every settings-loading binary calls
`vike_config::refuse_removed_env` first — before a window, a feed, a core or a venue mount — and a
set, non-empty removed variable refuses to start, naming the file, the key, and the operator's own
value as a paste-ready line.
A present-but-empty value starts normally: it configured nothing under the old readers either.
The same rule covers a removed *file*: a `/vike.toml` override layer existed for one hour
and is refused by name if found — and a path that cannot be probed refuses too, but says so rather
than claiming a file is present.
## A key nothing reads is worse than a missing feature [#a-key-nothing-reads-is-worse-than-a-missing-feature]
A settings key that validates, is accepted by the loader, and is reported by `vike-cli config show`
as the origin of an effective value — while being read by nothing — hands the operator positive
confirmation of something false. `crates/vike-config/src/consumed.rs` records that it is not
hypothetical: a clean-install validation found `flags.tradehub_control`, `config.tradehub_addr`,
`config.log_dir`, `config.state_dir` and `preferences.log_file_level` all displayed as effective
while read by nothing — no control server, the log in the wrong directory, and a `trace` firehose
the operator believed they had capped, which once wrote 341 GB onto the disk hosting a live trading
node. `vike_config::CONSUMPTION` is the answer and the ratchet: one row per `config`, `preferences`
and `flags` key naming the file and the read itself, or a written admission of which reader still
owns the variable today. A test opens each claimed consumer and looks; a needle inside the config
crate is rejected, because parsing a field is not consuming it. `config show`'s `READ` column
prints the verdict per key, and names the binary where one program owns the read (a library
consumer, read by every binary that links it, prints `yes` instead). The policy ceilings have an
older twin of the same gate; both are on [settings gates](/docs/trader/developer/settings-gates).
Provenance is measured the same way: `crates/vike-config/src/provenance.rs` decides an origin by key
**presence in a parsed layer**, never by diffing values against defaults. Unknown keys are rejected
by name, a removed key is a tombstone whose error says where the concept lives now, and a truthy
typo on a flag (`VIKE_RECONCILE=true`) is an error rather than a silent `false`. Every flag defaults
to `false`, and `FLAG_REGISTRY` gives each an owner and a review date, gated in CI — a flag with no
owner is a flag nobody will ever delete.
## Where the project is [#where-the-project-is]
`` is resolved at runtime by walking up from the working directory for a project marker —
never a compile-time path — and `VIKE_SETTINGS_DIR` beats the walk by naming `/settings`
outright, the project being its parent; the shipped service units set it. The loader itself
resolves nothing: the settings directory and the environment map are parameters, so the whole load
is a pure function of its arguments, and a missing file is not an error — the layer below stands.
The three binaries that load settings do so **before** initialising logging, because the log
directory and both log levels are themselves settings. The
[architecture](/docs/trader/concepts/architecture) page carries the rule that one walk decides per
process.
## Credentials [#credentials]
`secrets.env` is the one store: no precedence, no second location, so "which file are my keys
coming from" has a one-line answer. Absent credentials *are* the live gate — no store means an
empty map, every venue loader returns `None`, and every venue stays paper. A store that **exists
and cannot be read** is the opposite case and errors, because "not configured" and "cannot open"
must never look the same to an operator. A permission finding on a group- or world-readable file
is reported, never enforced: refusing would strand somebody mid-setup with every venue on paper,
while reading it silently is worse still. Nothing ever regenerates, reorders or deletes the store;
the one sanctioned write is a byte-preserving upsert of named keys.
The question "should settings live in SQLite, since they change often?" was asked and measured.
Across the four TOML files there were three non-empty settings lines in total. The decisive
argument against an embedded store is that the change journal has two live writers — the daemon
and the desktop app — and an exclusive file lock would make the second fail to open it. So settings
and credentials stay files, and the change history the question actually wanted is an append-only
JSONL journal beside them, `vike_model::change_journal`, under `/settings/state/changes/`.
The record it replaces was a `tracing` line at `info` on a daemon whose file level was `warn`: a
23 MB log held 53 160 error lines, 1 928 warnings and zero of the records.
Each file's keys, with their consumers, are on the [settings reference](/docs/trader/reference/settings/index);
the removed variables and the registry of every environment read are on
[environment variables](/docs/trader/reference/environment).
# Startup preflight (/docs/trader/concepts/startup-preflight)
`crates/vike-mount/src/preflight.rs` opens with the sentence that justifies it: on mainnet day the
first real order must not be the probe. A drifted host clock used to be discovered as a binance
`-1021 INVALID_TIMESTAMP` on the first signed order, a dead key at the first `submit`, a full disk
by the recorder and the journal dying in silence, a dead resolver by every reconnect failing at the
hostname step. The preflight turns each into a structured report an operator reads at t=0.
## Pure core, injected probes [#pure-core-injected-probes]
Nothing in `preflight.rs` performs I/O. Every observation arrives through `PreflightProbes`, a
four-method trait — local time, a venue's server time, a cheap authenticated read, free bytes under
a directory — so the whole decision surface is unit-testable with no network, venue or filesystem.
`crates/vike-mount/src/startup.rs` is the one production wiring: `run_startup_preflight` builds the
real probes and `vike_run::build_node` runs it at the top of the venue mount. An unwired leg returns
`NO_PROBE`, which never reads as a pass: the clock and disk legs warn — "could not measure" is not
evidence of a fault — while the credential leg **fails**, because "we could not prove these keys
work" must not mount a venue live.
## The clock leg [#the-clock-leg]
A clock reading has four outcomes. A measured skew is judged against that venue's own thresholds; a
venue that publishes a clock and did not answer warns; a venue declared to have no clock leg where a
drift costs it nothing is not-applicable and never a warning; and a venue with no leg whose auth
signs the clock into the order path warns as a disclosed gap — polymarket is the one such row.
`crate::server_time` is the roster-gated table that declares which is which.
The thresholds are per venue because one global number was measurably false. The binance and bybit
signers hard-code a 5000 ms `recv_window`, shared between local skew, one-way latency and venue
queueing, so `DEFAULT_CLOCK_FAIL_MS` is 2500 — half the budget, past which one ordinary latency
spike pushes orders outside the window — and `DEFAULT_CLOCK_WARN_MS` is 500. Only a venue that
rejects an order over drift may fail: deribit and ig stamp no timestamp and hyperliquid binds the
clock into a nonce valid for about a day, so they carry no fail threshold and warn at
`CANARY_CLOCK_WARN_MS`, 1000 ms. The measurement that forced the split, from an NTP-disciplined
prod2 on 2026-08-09: bybit read +9..23 ms, okx +10..37, aster +15..33 — while forty samples of
hyperliquid's testnet node inside three minutes ranged from -220 to -424 ms. That is the venue's
clock wandering, not the host's.
A reading is only as sharp as its round trip: the midpoint correction assumes a symmetric path, so
the verdict is taken from what the reading proves — `|skew| - rtt/2`, floored at zero. Bybit's demo
host once read 182 ms of apparent skew on a 549 ms round trip while every other sample read 13-23
ms; under the floor, a slow link cannot manufacture a warning. Up to `DEFAULT_CLOCK_SAMPLES` (3)
readings are taken, the smallest round trip wins, and resampling happens only while the band still
straddles a threshold — one call in every ordinary case.
Each clock read is a blocking REST call before the first venue mounts: seven wired venues, three
samples each, on the shared 30 s agent is 630 s of possible stall. Two bounds replace that — a 3 s
per-read `CLOCK_READ_TIMEOUT`, and a leg-wide budget from `clock_budget_for` of 1200 ms per venue
actually read, clamped between 5000 and 15000 ms — so the worst case is 18 s at the ceiling and
11.4 s today. The fixed 5000 ms it replaced was sized against a six-read 1781 ms measurement; by
2026-08-22 a healthy pass summed to 2973 ms, so one dead venue needed 5973 and eight venues went
unread. The credential leg is bounded separately: `CREDENTIAL_PROBE_TIMEOUT` is 5 s per attempt,
`CREDENTIAL_PROBE_ATTEMPTS` is two — one retry, spent only on a probe that answered with an error,
never on a timeout — and `credential_budget_for` is 3000 ms per probed venue clamped between
6000 and 20000.
## The credential leg, and why the demotion is enforced [#the-credential-leg-and-why-the-demotion-is-enforced]
One probe is built for each of five venues — binance, bybit and okx through `build_recon_client`,
plus alpaca and ctrader through their own bridge factories — whose credentials resolve and whose
ceiling arms them, each performing that venue's own cheap authenticated read through its reconcile
client. Every other reconciled venue builds its `ReconClient` inside its own mount arm, which this
pre-mount step never calls, so it is never probed — and preflight never demotes a venue it did not
check. ctrader authenticates during construction, so its probe defers construction into the
closure, because building it eagerly would turn a refused grant into no row at all. Every probe's
error names the host it could not reach.
For its whole life the report was advisory — a fail row was logged and the venue mounted live
anyway, because a transient timeout raised the same fail as a refusal. `CredentialGap` removes that
blocker. `Rejected` means the venue answered inside the bound and refused the signed
read — a fail, and the venue is mounted paper for the session. `Unanswered` means nothing came back
inside the bound — a warning, and the venue mounts as configured. The enforcement withholds every
`{VENUE}_`-prefixed key from the map the mount reads, so `make_engine` sees absent credentials and
lands on the paper fallback it always had: refused keys and absent keys are the same fact, and the
old behaviour mounted live precisely when the venue had just said the keys do not work. One residual
is stated: a fast non-auth error such as a `429` inside the bound reads as rejected and demotes; the
retry stops a single blip doing it, and paper is the safe side.
A per-venue fail never flips the go bit. Only a global check — no disk headroom, no network — makes
`PreflightReport::go` false, and even that does not ground the process: a daemon that refuses to
start under `Restart=on-failure` crash-loops, so a global fail is logged at `error` and the mount
proceeds.
## Disk, network, and what the arming ceiling gates [#disk-network-and-what-the-arming-ceiling-gates]
The disk leg was dark for a year because free space is not reachable from `std`; it is armed on
unix through a safe `rustix::fs::statvfs` and reports a declared reason on Windows, warning below
5 GiB and failing below 1 GiB over directories the caller passes — which is how it measures the
journal directory the mount actually built. The network leg spawns a `NetProbe` and waits at most
500 ms for its first round.
Every venue set is derived through `would_mount_live_under_policy` — the same predicate the mount
arms on, so preflight and mount cannot disagree about what is armed. Until that was threaded, a
deployment whose [policy](/docs/trader/reference/settings/policy) said `ig = "paper"` still had IG
contacted at every start, with a credentialed read. `policy: None` reads all-paper and contacts
nothing; a box with no credentials, or a full store under an all-paper policy, makes not one
network call. `VIKE_PREFLIGHT_SKIP=1` — the exact string, read from the real process environment —
removes every leg. Probe errors never carry a key, secret or passphrase, the report's lines read
`[FAIL] credentials (okx): … — remediation`, and the [arming ceiling](/docs/trader/concepts/risk-and-arming)
stays the gate consulted above all of this.
# Timers (/docs/trader/concepts/timers)
Everything periodic in the live core rides one facility: `crates/vike-core/src/timer_wheel.rs`'s
`DeadlineTimerWheel`, armed and advanced by `crates/vike-core/src/runtime/timers.rs` at the
drain-loop boundary. Consolidating the stuck-order watchdog, both dead-man switches, managed
expiry and the observers onto one wheel means none of them can touch the per-message fold: the
wheel is advanced once per drain batch, after the batch has folded, and an empty wheel costs a
single `is_empty` read. This page is written from those two modules and from the `TimerKind`
enum in `crates/vike-core/src/runtime/mod.rs`.
## The wheel [#the-wheel]
It is a hashed timing wheel over a slab. Deadlines are quantized to ticks of `tick_ms` and
hashed into a power-of-two number of slots, each an intrusive doubly-linked list carved out of
one pre-allocated node slab with a free list. Insert reuses a free node — no allocation on the
steady path; the slab grows only when more timers are concurrently armed than ever before —
and cancel is an O(1) unlink. A `TimerId` carries a generation, so a stale id whose slot has
since been reused is rejected rather than cancelling the wrong timer.
The boundary advance is O(1) in the common case: a monotonically maintained lower bound on all
armed deadlines (`min_tick`) makes "nothing is due yet" a single integer compare regardless of
how many timers are armed. When something is due, expiry walks only the crossed slots and the
bound is re-established by one slot scan; an advance that crosses a whole rotation — a long
idle gap — takes a full pass that sorts the due nodes so emission stays in nondecreasing
deadline order.
Every method that needs "now" takes it as an injected `now_ms`. The wheel is a pure function of
its inserts, cancels and the values passed to `advance`, so a replay or harness driving it with
recorded stamps reproduces the exact expiries. A deadline never fires early — it is quantized
up to the next tick, so a coarser resolution only makes a timer fire up to one tick late — and
time only moves forward: an advance to a `now_ms` at or behind the cursor is a no-op. A deadline
already in the past is clamped to fire on the next advance rather than stranded in a slot the
cursor has passed.
The runtime builds it as `DeadlineTimerWheel::new(50, 1024)`: 50 ms resolution times 1024
slots, about a 51 s rotation, which covers a typical watchdog half-timeout in the bounded
stepping path and falls back to the full-rotation pass for larger ones.
## Reaching the boundary when the core is idle [#reaching-the-boundary-when-the-core-is-idle]
The core parks in `blocking_recv` between messages, and a parked core never reaches the
boundary on its own. An OS waker thread injects an `Ingest::Watchdog` message on cadence whose
dispatch arm is empty — it exists purely to wake the loop so `drive_due_timers` runs. That
waker record is journaled only when a wall-clock sweep that mutates order state can ride it —
the stuck-order ladder or either [dead-man's switch](/docs/trader/concepts/dead-mans-switch) — and its
presence is what makes a session's
journal refuse replay; with neither on, the boundary can only run replay-neutral observers, the
waker is not written, and the session still replays. The rule is on the
[replay](/docs/trader/concepts/journal/replay) page.
## What is armed, and when [#what-is-armed-and-when]
`arm_boundary_timers` runs once at startup and arms the first six kinds below; each of them
re-arms itself when it fires. When nothing is opted in the wheel stays empty and the advance is
skipped, byte-identical to a wheel-free runtime.
| kind | runs | cadence | armed when |
| ------------------ | --------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------- |
| `StuckSweep` | the [watchdog](/docs/trader/concepts/watchdog)'s stuck-order ladder | `submit_ack_timeout / 2`, at least 50 ms | `submit_ack_timeout` is set |
| `DeadManSweep` | the silence [dead-man's switch](/docs/trader/concepts/dead-mans-switch) | `timeout / 2`, at least 50 ms — at least two checks per window | `deadman` is set |
| `LinkDeadManSweep` | the connection [dead-man's switch](/docs/trader/concepts/dead-mans-switch) | `grace / 2`, at least 50 ms — the same shape, on its own window | `link_deadman` is set |
| `InflightConfirm` | the reject-free early confirm | the configured interval — it *is* the check rate | `inflight_confirm` is set |
| `GtdSweep` | managed GTD/DAY expiry | the configured interval | `gtd_sweep` is set |
| `PortfolioSnap` | one compact equity-and-positions journal record | the configured interval | `portfolio_snapshot_interval` *and* a journal are both set |
| `EquitySample` | the equity sampler | the configured interval | `equity_sample` is set and any position is open |
| `StateSave` | every mount's strategy-state save | the configured interval | `state_save` and `state_dir` are set and any mount exists |
The self-rescheduling sweeps re-arm at `now + tick`, skipping missed ticks rather than
bursting catch-up sweeps after a long idle. `drive_due_timers` stamps the injected `now_ms` onto
every engine before acting, so a fired sweep sees the timestamp semantics the old per-message
dispatch gave it.
The last two rows are never armed at startup: `maintain_equity_timer` and
`maintain_state_save_timer` arm and disarm them at the boundary as their gate opens and closes,
and a fired one re-arms itself only while that gate still holds. The sampler stays armed while any
engine holds an open position and is cancelled the instant the book goes flat; `any_position_open`
tests `size != 0.0` rather than map emptiness, because a closed position leaves a zero-size entry
behind forever. The state save deliberately does not gate on open positions — a breaker's trip
count or an estimator's accumulator matters whether the book is flat or not — and is cancelled
the moment every mount is gone. Two more boundary probes have no timer at all and re-walk their
subject every pass: the per-mount readiness gate, which no-ops in O(1) once every mount has
priced, and the wall-clock strategy schedule, driven off the clock *value* because an injected
clock may not advance between passes.
## What the observers do [#what-the-observers-do]
`sample_equity` resolves each engine's equity through the same `resolve_equity` and
`CoreConfig::price_cfg` the snapshot uses, notes every open position's priced-or-missing state
on its engine's [price board](/docs/trader/concepts/price-board) so the warn-once set arms and
clears, and hands one row per engine plus a `"TOTAL"` cross-venue row (the same `py_sum` law as
`equity_total`) to the `on_equity_sample` sink — the core takes no data dependency, so the
caller wires persistence. `save_all_strategy_state` is the one save loop shared with clean
shutdown: each mount's `Strategy::save_state` runs under `catch_unwind`, a `Some` is written
atomically to its sidecar, and an error is logged and swallowed. `write_portfolio_snap` appends
a `PortfolioSample` — per-venue equity, balance, realized, and every open position — to the
[journal](/docs/trader/concepts/journal/) best-effort; it is replay-neutral and does not advance the
snapshot cadence counter.
Because those records never bump the write-ahead counter that paces the replay-base `Snap`, an
otherwise idle journaling core never reaches the snapshot threshold, so nothing is prunable
until the shutdown `Snap`. A 1 s cadence on a silent weekend is about 86 000 unprunable records
a day. Size the cadence for the session — seconds to minutes for a reporting series, never
sub-second.
# The watchdog (/docs/trader/concepts/watchdog)
`crates/vike-core/src/runtime/watchdog.rs` holds the core's cold-path sweeps: the stuck-order
ladder, the fast in-flight confirm, managed good-till-date expiry, safe-state entry, the
margin-call sweep and the equity-drawdown latch. None runs on the per-message fold; the timed
ones fire from the [timer wheel](/docs/trader/concepts/timers) at the drain-loop boundary and the
others on a closed bar, so the [latency gate](/docs/trader/concepts/latency-gate) is untouched. This
page is written from that module and from the `CoreConfig` fields in
`crates/vike-core/src/runtime/mod.rs` that arm each sweep.
## The stuck-order ladder [#the-stuck-order-ladder]
`sweep_stuck_orders` is the last-resort backstop for an order stuck before acknowledgement
(`Initialized` or `Submitted`): an adapter that accepted `submit` but whose thread wedged before
emitting any terminal. It must never race a slow-but-real venue ack into a phantom reject — if
the venue actually accepted or filled the order, a synthesized `OrderRejected` strands a real
position, because the later real `OrderAccepted` or `OrderFilled` is an illegal transition from
`Rejected` and is dropped (counted as `stranded_terminal_drops`). So it is a ladder, armed by
`CoreConfig::submit_ack_timeout` (`None` by default) with `submit_ack_confirm_grace` (default
15 s):
* **Stage 1** — at `created + submit_ack_timeout`, soft-warn and actively `confirm` once: the
venue is prodded into a status re-query whose authoritative terminal folds back on the normal
event lane. Nothing is terminalized, and the time of the confirm is recorded.
* **Stage 2** — the plain reject deadline is `created + submit_ack_timeout + grace`, but a
candidate whose stage-1 confirm is presumed in flight (it is still pre-ack) is deferred one
further grace window, to `+ 2·grace`. Only past that, still pre-ack, is the `OrderRejected`
synthesized, with the reason `watchdog: no venue ack within confirm-grace`. An order that
reaches the deadline without ever passing stage 1 — a busy core skipped ticks, or the clock
jumped — is confirmed now and deferred, so *no backstop reject without at least one active
confirm plus its grace window* holds.
The core fold thread issues no network call: `confirm_order` is fire-and-forget onto the
adapter's own thread, and the sweep only waits for the terminal. Reconcile-seeded orders, which
carry no creation time, are never swept. The bookkeeping is pruned to still-pre-ack orders at
the end of every sweep.
`submit_ack_timeout` must exceed the venue's worst-case order-visibility latency and the
adapter's own submit-plus-requery budget; the doc suggests a conservative value such as 30 s for
reconcile-less venues. The grace must clear the confirm's own round trip — the stated bound is
`2·grace > submit_ack_timeout/2 + N·requery_timeout`, with a re-query timeout of about 5 s and
`N` = 2 for Bybit (a realtime-then-history re-query, worst case about 10 s) or 1 for OKX. A
zero grace collapses the ladder back to immediate reject and reintroduces exactly the
phantom-reject risk the window exists to remove.
## The reject-free early rung [#the-reject-free-early-rung]
`sweep_inflight_confirms`, armed by `CoreConfig::inflight_confirm` (`None` by default,
suggested live value about 2 s), re-queries an order stuck pre-ack on a fast cadence, catching a
wedged adapter in seconds instead of waiting the conservative timeout for the ladder's first
confirm. Three guarantees make it safe: it calls only the publish-nothing `confirm_order` and
can strand nothing; it is band-limited to ages in `[inflight_confirm, submit_ack_timeout)`,
handing off the instant an order crosses the timeout so the two rungs never act on one order at
one age; and it keeps its own dedup map, re-confirming a still-stuck order at most once per
window. With the ladder off the band is unbounded and this becomes the only stuck-order signal
— still reject-free, because without a configured reject timeout the core must never invent a
terminal.
## Managed expiry [#managed-expiry]
`sweep_gtd_expiry`, armed by `CoreConfig::gtd_sweep`, cancels every resting order whose
time-in-force deadline has passed, for venues that do not expire GTD or DAY orders themselves.
The decision is `vike_model::tif_expired`, the one expiry law shared with the paper book; the
terminal vocabulary differs on purpose. The paper book owns its resting order and mints
`OrderExpired`; this sweep asks the real venue to cancel and lets the venue's authoritative
`OrderCanceled` fold back, because publishing `OrderExpired` locally would desync local state
from a venue order still live. Each expiry is journaled write-ahead as `GtdExpire` — a marker,
never re-applied on replay — and the cancel is fire-once per order.
## Safe-state entry [#safe-state-entry]
`enter_safe_state` is what a fold panic reaches. It logs the reason once at `error`, sets
`trading_state = Halted` on the primary and every extra engine so the risk gate denies every
new order, records the fault for the snapshot, and issues a best-effort cancel for every
non-terminal working order tagged `CancelIntent::RiskOff` — the core has faulted and is pulling
its whole book, so a venue metering its cancels must not hold any back. The sweep is itself
guarded: a client that panics during it cannot take the loop down. What the
[execution core](/docs/trader/concepts/execution-core) does afterwards — read for display, never
traded on again until restart — is that page's subject.
## The margin-call sweep and the drawdown latch [#the-margin-call-sweep-and-the-drawdown-latch]
Both run once per closed bar of the engine's own series, where marks are fresh, and both read
equity through the resolver the snapshot displays. `sweep_margin_call` runs the
[margin-call](/docs/trader/concepts/margin-call) model per engine and submits each liquidation as a
`reduce_only` market order through the normal gate, routed to the breaching engine by index.
`sweep_drawdown_latch`, armed by `CoreConfig::max_drawdown` (`None` or any value at or below
zero disables it), folds a high-water mark of the daemon's own equity curve and, when the drop
exceeds the threshold, sets every engine to `TradingState::Reducing` so only reduce-only orders
pass. It mints no orders, pushes one warning naming the mark, the curve and the drawdown into
the recent-events ring, and never un-latches on its own — recovery is a deliberate manual
`SetTradingState`.
The curve is `Σ seed_of(engine) + Σ resolved_own_pnl(engine)` — configured capital plus the
daemon's own realized and unrealized P\&L — and deliberately not observed account equity, which
on a venue-attested account is the wallet for the whole account the credentials open. Measured
on prod2 on 2026-08-17: nine paper mounts at 1000 seed each plus one bybit mount carrying
53647.10600813 of a shared demo wallet latched a high-water mark near 62647, where a third party
withdrawing would trip liquidate-only with no trading at all, and a genuine 25% loss on the
daemon's own \~9000 of book read as 3.6%. Across a restart the peak seeds to `max(capital base,
curve)`, so the curve resumes underwater rather than forgiving earlier losses; a non-positive
peak cannot express a fraction, so a profile with `max_drawdown` set is refused unless its seed
cash is positive.
An operator facing an order that will not leave `Submitted` starts at
[triage a stuck order](/docs/trader/guides/triage-a-stuck-order); the venue-side truth the ladder
defers to is [reconciliation](/docs/trader/concepts/reconciliation/).
# Databento (/docs/trader/data-providers/databento)
`databento_backfill` is the crate's adapter for Databento's historical HTTP API. Its module,
`crates/vike-backfill/src/databento/mod.rs`, describes the lane in one sentence: intraday
bars/trades/quotes/L2-book from `hist.databento.com/v0/timeseries.get_range` in the raw CSV
encoding, into the `vike-data` hist store. It is a data source rather than an execution venue, which
is why it lives in vike-backfill and not under `crates/bridges/`. The bin is built only under the
`databento` feature (`crates/vike-backfill/Cargo.toml`), so the invocation is
`cargo run -p vike-backfill --features databento --bin databento_backfill`.
## What a run does [#what-a-run-does]
From `crates/vike-backfill/src/bin/databento_backfill.rs`'s usage text:
```
databento_backfill --dataset GLBX.MDP3 --symbol ESZ4 --schema trades \
--start 2024-01-02T00:00:00 --end 2024-01-02T00:01:00 [--store DIR] [--venue NAME]
```
`--dataset` is the Databento dataset code, `--symbol` the instrument within it, and `--schema` one
of `trades`, `ohlcv-1m`, `ohlcv-1h`, `ohlcv-1d`, `mbp-1`, `mbp-10` — each mapping to a store series.
`--start`/`--end` are in Databento's own timestamp form. `--store` resolves through [the shared
ladder](/docs/trader/concepts/hist-store), where `$VIKE_HIST_STORE` and a project declared by
`$VIKE_SETTINGS_DIR` both outrank `/market_data/hist`, and `--venue` is the label the series is
stored under, defaulting to `databento`.
The transport (`crates/vike-backfill/src/databento/client.rs`) builds the `timeseries.get_range`
URL, authenticates with HTTP Basic — the API key as username, empty password — and streams the CSV
body to a temporary file rather than buffering it, because tick and book ranges are large. The
ingest (`crates/vike-backfill/src/databento/ingest.rs`) drives the parsers into the store under the
commit key `databento:{kind}:{symbol}:{start}-{end}`, so re-running the same window is a store
no-op.
## The parsers [#the-parsers]
`crates/vike-backfill/src/databento/parse.rs` is pure — no I/O, no clock, fixture-tested — and its
header carries the wire facts a reader of the stored rows needs: prices arrive as `1e-9` fixed-point
integers and are divided by `PX_SCALE`; timestamps are nanoseconds and are reduced to milliseconds;
the trade `side` is the aggressor, `A` meaning a sell aggressor (the buyer is the maker) and `B` a
buy aggressor. Columns are resolved by *name* through `crates/vike-backfill/src/csvutil.rs`'s
`Header`, which the two vendor parsers share, so column-order drift between schema versions is
tolerated. `csvutil` also explains why no CSV crate was pulled: both vendors emit a header row and
only numeric or string fields with no embedded commas, so a plain split is sufficient.
## Credentials [#credentials]
`DATABENTO_API_KEY` is read by the bin from the credential store — `/settings/secrets.env`
— then the process environment, never argv (bin header). `client.rs`'s header records the
boundary: the key arrives as a plain `&str` parameter, and the module reads neither the environment
nor the workspace `.env`. It used to own an `api_key_from_env()` while its Tardis sibling already
had the bin do the read; the two premium-vendor adapters are now symmetric. The live smoke for this
adapter, `crates/vike-backfill/tests/databento_smoke.rs`, is `#[ignore]`d and self-skipping — its
ignore reason reads "live: needs DATABENTO\_API\_KEY, spends a tiny amount".
## Where it sits in the capability table [#where-it-sits-in-the-capability-table]
`crates/vike-backfill/src/caps.rs`'s Databento row is `NONE` for every roster venue, and the comment
on it is the point rather than a gap: the vendor has zero crypto coverage (verified in the vendor
study), so it is an equities and futures answer, keyed off its own vendor-namespaced venue label
rather than a roster venue — which is why no roster row is `true` for it, and why the `--venue`
default is `databento`. A gap plan over a roster venue will therefore never offer Databento; you
reach it by naming the dataset yourself.
The key names `{start}-{end}`, so re-running the identical window is a no-op, but two runs over
windows that overlap are two keys, and the store compares keys, never row values
(`crates/vike-backfill/src/vikedata/ingest.rs` names this as the same contract
`databento/ingest.rs` has). Schedule non-overlapping windows rather than widening a trailing one;
where two writers already cover the same rows, the store's source-precedence policy on
[the Data API page](/docs/trader/data-providers/vike-data-api) is the repair.
The store kinds a schema produces are documented under [concepts/data](/docs/trader/concepts/data); the
comparable crypto-only vendor lane is [Tardis](/docs/trader/data-providers/tardis).
# EOD, events and exec trades (/docs/trader/data-providers/eod-and-events)
These three `vike-backfill` bins share nothing but the store they write. Each takes `--store DIR`, resolved through the store's own ladder — `$VIKE_HIST_STORE` and a `$VIKE_SETTINGS_DIR`-declared project both above `/market_data/hist` — and each is idempotent through the store's commit-key append, so a repeated run is a no-op rather than a duplicate. What they fill is described on [the history store](/docs/trader/concepts/hist-store); the column shape of each kind is under [concepts/data](/docs/trader/concepts/data).
## `eod_backfill` — daily bars [#eod_backfill--daily-bars]
```
eod_backfill --source yahoo --symbols SPX=^GSPC,VIX=^VIX,NDX=^NDX,DJI=^DJI --years 10 [--venue V] [--store DIR]
```
Daily OHLCV for equity indices and stocks, ingested as `kind=bar` at interval `1d`, idempotent per run-day. `--symbols` is a comma list of `vikeSymbol=sourceTicker` pairs; a bare `SYM` means the source ticker equals the symbol. `--years` defaults to 10. `--venue` defaults to the source name, so provenance lives in the partition.
`crates/vike-backfill/src/eod/mod.rs` is explicit that these are read-only data sources, not execution venues, which is why they live in the backfill crate rather than under `crates/bridges/`. The extensibility point is the `EodSource` trait: a provider builds a URL, GETs it, and hands the body to a pure, fixture-tested parser — `parse_yahoo_json` for the one shipped provider. `vike_backfill::eod::SOURCES` is exactly `["yahoo"]`. A stooq provider was dropped after a live test found it fronting its CSV endpoint with a JavaScript proof-of-work wall a plain HTTP client cannot pass.
The Yahoo source (`crates/vike-backfill/src/eod/yahoo.rs`) requests `query1.finance.yahoo.com/v8/finance/chart/{ticker}?interval=1d&range={years}y`. It needs a `User-Agent` header — Yahoo answers 403 without one — and index tickers carry a `^` prefix (`^GSPC`, `^VIX`, `^NDX`, `^DJI`). A day with any `null` OHLC field is skipped, which is how Yahoo marks a no-trade day.
The module doc calls this source keyless but ToS-gray and best-effort: the official Yahoo API is retired, and the unofficial v8 endpoint can break without notice. The bin exits non-zero only when every symbol failed; a single failed ticker is logged and skipped.
## `events_api_backfill` — the live events API [#events_api_backfill--the-live-events-api]
```
events_api_backfill --from YYYY-MM-DD --to YYYY-MM-DD --tokens-file PATH [--tokens T1,T2,...]
[--store DIR] [--base URL] [--kind book|trade|quote|all] [--concurrency N] [--dry-run]
```
This is the no-ClickHouse, no-multi-GB-download route to Polymarket L2 history: `GET https://data.vike.io/v1/events?token_id=...&from_ms=...&to_ms=...&limit=...[&cursor=...]`, paged JSON, one token at a time — the rows a customer wants for the one token they care about. Its bulk-Parquet sibling is `vike_archive_backfill` on [the Data API page](/docs/trader/data-providers/vike-data-api); the two share the `vike-archive` Cargo feature (this bin is built only under it) and the same `X-API-Key` credential, `VIKE_ARCHIVE_API_KEY`, which the bin reads from the credential store and passes into the library as a plain parameter — `vike_backfill::events_api` itself never touches the environment. Series land under `venue=polymarket` with commit key `eventsapi:{kind}:{token}:{day}`, keyed per token because the endpoint is fetched per token.
One (date, token) pair is one unit of work: the UTC calendar day `[midnight, next midnight)`, paged to completion by `EventsClient::fetch_all_single`. `--concurrency` (default 6) runs that many fetch loops on worker threads — the measured \~0.81 s per market round trip is latency-bound, so overlapping requests is the win — while every decode and store append happens on the main thread, single-writer. `--tokens` and `--tokens-file` (one id per line, `#` comments allowed) union into one list; giving neither is an error, since "no filter" has no meaning for a per-token endpoint. `--dry-run` prints the work-unit count and the base URL with no network call and no store opened.
`crates/vike-backfill/src/events_api.rs` documents three ways this endpoint differs from every sibling path, all verified against real traffic on 2026-07-28:
* A `price_change` delta row already carries its one-level ladder in `bids`/`asks`, including a zero-size removal, so book rows are parsed uniformly for snapshots and deltas.
* `best_bid`/`best_ask` are always `0.0` — more than 10,000 consecutive rows of an actively traded `btc-5m` market showed no non-zero value. `--kind quote` is therefore a harmless no-op lane, and an events-API-sourced store has no quote lane until the server populates those fields.
* No `tick_size` field exists on the wire. `book_update_from_row` defaults to `FALLBACK_TICK_SIZE` (`0.01`, the venue's standard tick) because `0.0` is actively harmful: `L2Book::new` falls back to `1.0` for a non-positive tick, quantizing a `[0.0, 1.0]` market into two buckets. Over a real 26-market slice every tick-sensitive maker config traded zero times while `trailing_scalper`, which prices off the trade tape, traded normally — the data was sound; the tick metadata was missing. A market on a genuinely finer or coarser tick is still misquantized.
Multi-token requests are detected, not assumed: with two or more tokens the bin makes one `probe_multi_token` call first. On 2026-07-28 the live endpoint still answered `HTTP 400`, so one unit per token is what actually runs; the decoder already parses a per-row `token_id`, so a batched response will decode the day it ships.
## `exec_trade_backfill` — your own fills [#exec_trade_backfill--your-own-fills]
```
exec_trade_backfill --file --symbol BTCUSDT [--venue binance] [--store DIR]
```
This one imports a venue account's trade-history export into the `kind=exec_fill` execution log — the offline twin of the live `JournalMaterializer`. Both write the same series, so live and historical fills converge into one queryable log. The v1 input is a Binance `myTrades` JSON export, which needs no credentials. The commit key embeds the fill window, so re-importing the same export is a store no-op.
`vike_backfill::exec_import::parse_binance_my_trades` is pure: no clock, no network. A malformed or short row is skipped rather than aborting a bulk import; numeric strings are taken in Binance's wire format; `side` is +1 for a buyer and −1 for a seller; `commission` is the positive charged fee; `isMaker` maps to `maker`/`taker` and, when absent from a minimal export, the liquidity side stays empty rather than guessed. An `ExecTradeSource` trait exists for a signed live-REST source per venue, but v1 registers none — the export file is the only path.
# Funding rates (/docs/trader/data-providers/funding-rates)
Two bins in `crates/vike-backfill` carry "funding" in their name, and
`crates/vike-backfill/src/funding_rate.rs`'s header draws the line between them before anything
else. `funding_rate_backfill` writes the **market** rate: the venue-wide, position-independent
funding schedule for a symbol — keyless, available for every perpetual whether or not you hold it,
and the *signal* a funding-capture strategy trades on. `hyperliquid_funding_backfill` writes an
**account's** realized funding: a wallet's `userFunding` payments, which is a different series with a
different store kind. Both sit behind the `venue-backfill` feature, and the second reason is not
obvious: `crates/vike-backfill/Cargo.toml` notes that `funding_rate_backfill` looks bridge-free but
routes through `crate::funding_rate`, which calls `vike-hyperliquid`.
## The market rate: `funding_rate_backfill` [#the-market-rate-funding_rate_backfill]
The collector fetches a venue's public funding-rate schedule and ingests it as `kind=bar`, the rate
riding on `vike_model::Bar::funding` with OHLCV and volume left `0.0` — so a backtest's
`funding_source` seam can replay the real per-interval rate off the stored column
(`funding_rate.rs` header). Two venues ship, each as an impl of the `FundingRateSource` trait — a
pure, fixture-tested parser over the venue's JSON plus a paged `fetch`:
* **Binance** — `GET https://fapi.binance.com/fapi/v1/fundingRate` with `symbol`, `startTime`,
`endTime` and `limit=1000`; 8-hour cadence; a keyless GET over the crate's shared HTTP client.
* **Hyperliquid** — `POST /info` with `{"type":"fundingHistory", ...}`; 1-hour cadence; a keyless
mainnet read over the bridge's own `HyperliquidTransport`.
Both venues cap responses at roughly 500–1000 rows, so `fetch` pages `startTime` forward until a
page comes back short or the cursor passes the end (`page_forward`, shared by both impls). The run
is idempotent per `(venue, symbol, [start, end])` window through `funding_rate_commit_key`.
From `crates/vike-backfill/src/bin/funding_rate_backfill.rs`'s usage: `--venue` selects the source
and names the store partition (`binance` | `hyperliquid`); `--symbols` is a comma list of
`vikeSymbol=sourceSymbol` pairs, bare `SYM` meaning the two coincide (Binance `BTCUSDT`, Hyperliquid
`BTC`); `--start`/`--end` are each an epoch-ms integer or a `YYYY-MM-DD` UTC date, `--end`
defaulting to now; `--interval` is the series interval label and defaults, for both venues, to the
cadence-agnostic namespace `funding` — `FundingRateSource::default_interval` is deliberately not a
cadence (`8h`/`1h`), because funding spacing varies by venue and by symbol, so the true cadence
lives in the data, in the spacing of the stored `Bar.ts`, and one stable label keeps this series in
its own keyspace where an OHLCV kline series can never interleave with it. An operator who wants a
cadence-tagged series passes `--interval`, as the second example below does. `--store` resolves
through [the store's ladder](/docs/trader/concepts/hist-store): `$VIKE_HIST_STORE`, then a project
declared by `$VIKE_SETTINGS_DIR`, then `/market_data/hist`.
```
funding_rate_backfill --venue binance --symbols BTCUSDT,ETHUSDT --start 2024-01-01
funding_rate_backfill --venue hyperliquid --symbols BTC=BTC,ETH=ETH --start 1704067200000 --interval 1h
```
The `funding_rate.rs` header records that `premium` was parsed and discarded for as long as the
file existed. It is the venue's funding premium for the same interval, not derivable from
`fundingRate` (the venue folds an interest-rate term in and then clamps), and it is the input the
cohort study's `premium_z_24h` feature needs. It is stored as its own kind — `kind=perp_metrics`,
one `PerpMetricRow` per interval that had one — rather than on the funding `Bar`, which has no field
for it. Binance sends no premium and therefore writes none. Open interest is deliberately not
beside it: Hyperliquid serves OI only as a current snapshot, never as history, so no backfill can
reconstruct a past value. The one producer that does fill `open_interest` is the vike.io hourly
panel, on [the Data API page](/docs/trader/data-providers/vike-data-api).
## The account's payments: `hyperliquid_funding_backfill` [#the-accounts-payments-hyperliquid_funding_backfill]
`crates/vike-backfill/src/bin/hyperliquid_funding_backfill.rs` fetches a master account's keyless
`userFunding` `/info` history on mainnet — no credentials, just the address — over
`[--start, --end]` and ingests it as `kind=funding`/`venue=hyperliquid`/`symbol=`, split by
coin and idempotent per `(account, coin, window)`. It is the funding analog of
`hyperliquid_backfill`, and `crates/vike-backfill/src/hyperliquid.rs` owns both: the candle backfill
and `backfill_hyperliquid_funding` with its pure `funding_rows_by_coin`, persisting through
`vike_data::HistStore::append_funding`.
`--account` is the master account address funding is read against. The bin header is explicit about
the agent-wallet pitfall the bridge guards against elsewhere: it does not apply here, because funding
reads are keyless — pass the **master** address. `--start`/`--end` take the same epoch-ms-or-date
forms as the market-rate bin, and `--store` resolves the same way.
```
hyperliquid_funding_backfill --account 0x8f0a3e01d916486735a8f6a2ffc0685a3fa57bf5 --start 2024-01-01
```
## Which one you want [#which-one-you-want]
A funding-carry strategy backtest wants the market rate, on `Bar.funding`, from
`funding_rate_backfill` — that is the number the position would have paid or received per interval
regardless of who held it. A reconstruction of what one wallet actually received wants
`kind=funding` from `hyperliquid_funding_backfill`. `crates/vike-backfill/src/caps.rs` tracks the
first as bars and does not track the second at all, with the reason on the Hyperliquid row: realized
funding payments are not market data, and no coverage report asks for them. The kinds themselves
are documented under [concepts/data](/docs/trader/concepts/data); the tutorial that consumes the market
rate is [funding carry across two venues](/docs/trader/tutorials/funding-carry-two-venues).
`vikedata_backfill --panel-funding` also writes funding-rate hours, and its usage text states that
the panel's rate and `fundingHistory`'s settled rate are different series — a snapshot aggregate
versus a settled interval rate, measured roughly 4× apart. The store's idempotency is batch-level on
the commit key, two producers are two keys, and the hours they share land twice. One store takes one
producer for a window.
# IBKR historical (/docs/trader/data-providers/ibkr-historical)
`ibkr_backfill` is the one venue-direct collector that needs a running desktop client rather than a
public endpoint. Its header (`crates/vike-backfill/src/bin/ibkr_backfill.rs`) states the shape:
it pages `vike_ibkr::HistoricalFetcher` — bounded below by the contract's `head_timestamp`, or an
explicit `--start` — for a symbol set and interval, and ingests each window as `kind=bar` under
`venue=ibkr`, idempotent per window. It needs a running TWS or IB Gateway, and the header adds why
that is enough: its login is the entitlement, and history is looser than realtime. The bin is built
only under the `ibkr` feature, which `crates/vike-backfill/Cargo.toml` enables together with
`vike-ibkr/ibkr-socket` — the vendored `ibapi` client — so a default build never compiles it.
```
cargo run -p vike-backfill --features ibkr --bin ibkr_backfill -- \
--env demo --symbols AAPL=AAPL.SMART.USD,MSFT=MSFT.SMART.USD --interval 1d --years 5
```
## The flags [#the-flags]
From the bin's usage text:
* `--symbols` — a comma list of `vikeSymbol=ibkrContract` pairs, required; a bare `SYM` means the
contract equals the symbol.
* `--env` — `demo` (which `paper` also spells) or `live`. The default is `demo`, and the bin's own
test names that as the one default whose direction is a safety property: a forgotten `--env` must
never reach a live account.
* `--interval` — `1d`, `1h`, `5m` or `1m` (default `1d`).
* `--years N` — how far back to page when `--start` is absent (default 5); it bounds the fetch when
`head_timestamp` is unavailable.
* `--start` — an explicit lower bound, epoch milliseconds or `YYYY-MM-DD`.
* `--what` — IBKR's `whatToShow` field: `trades`, `midpoint`, `bid`, `ask` or `bidask` (default
`TRADES`).
* `--store` — the hist-store root, resolved through [the store's ladder](/docs/trader/concepts/hist-store):
`$VIKE_HIST_STORE`, then a `$VIKE_SETTINGS_DIR`-declared project, then `/market_data/hist`.
* `--pacing-ms N` — the sleep between window requests, to stay inside IBKR's historical pacing
limits (default 10000).
Credentials come from the credential store: the usage text names `IBKR_{DEMO,LIVE}_ACCOUNT` in
`/settings/secrets.env` as required beside the running gateway.
## How it is split [#how-it-is-split]
`crates/vike-backfill/src/ibkr.rs` is the pure planning half — window sizing, paging and commit
keys — fixture-tested with no network, mirroring the EOD and pmxt collectors' shape of one
fixture-tested planner file. The bin drives `HistoricalFetcher` over those plans and ingests each
window through `HistStore::append_bars`. The fetcher itself lives in the bridge:
`crates/bridges/vike-ibkr/src/historical.rs`'s header describes a public historical-bar seam that
owns a dedicated `ibapi` connection, pages `historical_data` bounded below by `head_timestamp`, and
returns `vike_model::Bar`s — so the collector never names an `ibapi` type — reusing the same
symbology, bar-size mapping and bar mapper as the live feed. It is behind `ibkr-socket`, where
`ibapi` exists.
The collector is the offline twin of the live feed, and it is **live-verified on paper**: the proof
vehicle is `crates/vike-backfill/tests/ibkr_backfill_smoke.rs`, whose header calls itself the human
vehicle to verify the historical wire shapes before a real backfill run. It is `#[ignore]`d and
self-skips without a config or a running Gateway, so nothing in CI ever runs it.
Three call sites open a connection on `cfg.data_client_id`: the mount-time properties fetch
(`crates/bridges/vike-ibkr/src/properties.rs`'s `fetch_ibkr_properties`), the live feed
(`crates/bridges/vike-ibkr/src/market_feed/mod.rs`), and `HistoricalFetcher::connect`. Only the
first pair is sequenced by construction — the properties doc records that its connection is a
transient one-shot, "fully torn down before the live market-data feed … claims that same id, so it
never contends the feed", and it says nothing about a backfill. A backfill run beside a live feed
on the same configuration contends for the same id.
## Where it sits in the capability table [#where-it-sits-in-the-capability-table]
`crates/vike-backfill/src/caps.rs`'s row for `(Venue, "ibkr")` is `BARS_ONLY`, with the pipeline
written beside it: `HistoricalFetcher` → `vike_model::Bar` → `append_bars`. Like every venue-direct
source, it cannot repair a recorded trade or book gap; that is the finding the table's header
exists to make visible, and the reason the vendor lanes on [Databento](/docs/trader/data-providers/databento)
exist for equities and futures history at tick resolution.
The IBKR venue itself — the mount, the two transports, reconciliation — is documented under
[venues](/docs/trader/venues); the `ibkr_mount` binary that runs a real mount is on
[mount-bins](/docs/trader/reference/binaries/mount-bins). The bar kind this collector writes is under
[concepts/data](/docs/trader/concepts/data).
# Data providers (/docs/trader/data-providers)
Every backfilled series in the store was written by a collector, and every collector lives in
`crates/vike-backfill`. Its `crates/vike-backfill/src/lib.rs` header states the contract: venue
history *fetchers* live in the per-venue bridge crates, and what vike-backfill adds on top is the
historical backfill path — one-shot paged REST or `.bi5` pulls plus ingest into the `vike-data`
hist store, with commit-window bookkeeping — kept as its own crate so that machinery does not bloat
every bridge's live-feed code. Every collector is a **one-shot** binary an operator runs; the
long-running counterpart, `collector_supervisor`, dispatches the same `backfill_*` functions and
never a second implementation (`crates/vike-backfill/src/supervisor/mod.rs`'s contract). It has its
own page at [collector-supervisor](/docs/trader/reference/binaries/collector-supervisor).
Two facts shape everything below. Every bin here is a **composition root** — one of the few places
allowed to read the process environment and the credential store directly; that is the shape
`crates/vike-ops/src/settings.rs`'s `Layer::Binary` calls "the correct place to read process env",
and it is why each family's page names the variable its bin reads. And the crate is excluded from
CI's derived roster — `xtask/src/ci/tables.rs`'s `EXCLUDE_FROM_CI` carries it with the reason, that
it "only builds/tests under features" — so its families are compiled by the union `backfill`
feature lane rather than the default build.
## The families [#the-families]
| family | bins | feature |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| [Venue-direct](/docs/trader/data-providers/venue-direct) | `binance_backfill` `bybit_backfill` `okx_backfill` `aster_backfill` `deribit_backfill` `dukascopy_backfill` `hyperliquid_backfill` | `venue-backfill` |
| [Funding rates](/docs/trader/data-providers/funding-rates) | `funding_rate_backfill` `hyperliquid_funding_backfill` | `venue-backfill` |
| [Databento](/docs/trader/data-providers/databento) | `databento_backfill` | `databento` |
| [Tardis](/docs/trader/data-providers/tardis) | `tardis_backfill` | `tardis` |
| [The vike.io Data API](/docs/trader/data-providers/vike-data-api) | `vikedata_backfill` `vike_archive_backfill` | `vikedata` / `vike-archive` |
| [IBKR historical](/docs/trader/data-providers/ibkr-historical) | `ibkr_backfill` | `ibkr` |
| [Polymarket](/docs/trader/data-providers/polymarket) | `pmxt_backfill` `poly_reparse` | default / `poly-reparse` |
| [EOD, events and exec trades](/docs/trader/data-providers/eod-and-events) | `eod_backfill` `events_api_backfill` `exec_trade_backfill` | default / `vike-archive` / default |
| [Store maintenance](/docs/trader/data-providers/store-maintenance) | `migrate_to_group` `ingest_bench_bars` | default |
The feature column is not decoration. `crates/vike-backfill/Cargo.toml`'s `venue-backfill` note
explains why the venue collectors are off by default: every bin links the whole library, so while
the bridge crates were unconditional dependencies, all twenty-two bins compiled all of them —
including the fourteen that touch no venue at all. Each bin that needs a bridge now says so through
`required-features`. What cannot be gated is `vike-data/hist-datafusion`: every collector ingests
into the store, so DataFusion is the compile cost you always pay.
## What a source can and cannot serve [#what-a-source-can-and-cannot-serve]
`crates/vike-backfill/src/caps.rs` is the capability table, and its header opens with the finding
it exists to make visible: **no venue-direct backfill in this workspace serves `trade` or `book` at
all.** Every `*_backfill` bin that talks to a venue writes `kind=bar`, except Dukascopy, which
writes `kind=quote` from `.bi5` ticks and resamples bars from them. Trades and the L2 book come only
from archives and paid vendors — pmxt, `data.vike.io`, a local ClickHouse capture, Databento,
Tardis. That is exactly inverted from what "backfill from the venue" sounds like, and the
consequence is stated in the same header: the recorder writes `trade`/`quote`/`book`, so a hole in
a recorded tape generally **cannot** be repaired from the venue.
The table's `Source` enum names the five places a fill can come from, each with a one-line verdict
in its doc comment: `Venue` (the venue's own API — free, and only as complete as the venue exposes),
`Archive` (the `data.vike.io` / pmxt archives — paid per GB or keyless CC-BY, complete L2 plus
trades), `ClickHouse` (a local capture, prod-side), and the two paid vendors `Databento` and
`Tardis`. `backfill_caps(source, venue)` answers per pair; `sources_for(venue, kind)` is the Data
Manager's answer to a gap — which sources are even worth offering — and `is_unfillable` is the
honest empty result. Polymarket is the sharpest case and was verified rather than assumed: the
bridge implements no price-history endpoint, so its venue row is empty across every kind.
`crates/vike-data/src/datafusion_hist/sources.rs`'s header: commit-key idempotency stops the same
writer re-ingesting a window it already ingested. It does nothing about two different writers
covering one window — a `live-…` key from the recorder and a `databento:…` key from a backfill are
different keys, both accepted, and the store never dedups by row value. A `scan_trades` over that
window returns both copies, and a backtest sees double the volume with nothing reporting a
problem. `crates/vike-backfill/src/gapfill.rs`'s `plan_fill` exists so you do not create the
overlap: it emits only the missing days, per kind, narrowed to what `caps` says the source can
serve. Where the overlap already exists, the store's persisted `StoreSourcePolicy` resolves it —
the ranked commit-key prefixes `_sources.json` holds, which build the call-time `SourceRankPolicy` —
strict by default, skipping any series whose commit key matches no ranked prefix, and reporting
every superseded row.
`plan_fill` is day-granular off the `date=`-partitioned file index, so a day the recorder covered
for six hours reads as present and is never planned; within-day trust is a different question,
answered by `vike_data::quality::DayQuality` over scanned rows (`gapfill.rs`'s header).
## What every bin shares [#what-every-bin-shares]
`crates/vike-backfill/src/cli.rs` is the harness the `*_backfill` bins used to re-implement, and
its `store_root` is the one resolution law for every bin that has a default to resolve — the five
klines bins and `dukascopy_backfill` take the root as a required argument, and `ingest_bench_bars`
resolves its own `market_data/bench_hist` on purpose. The ladder: an explicit `--store`, then
`$VIKE_HIST_STORE`, then `/market_data/hist` when `$VIKE_SETTINGS_DIR` names that project
outright, then `/market_data/hist` when the box still has that checkout, then the project the
working directory sits in, then the per-user `…/vike-data` directory. The header
records why it had to become one function: the four "verbatim" bins honoured the environment
variable while pmxt/databento/tardis silently ignored it, so one operator variable routed half the
collectors to one store and half to another. The resolved root and the rung that produced it are
logged, because a store does not merge — if the default moves, the old store is simply no longer
read and a backfill reports zero rows into a fresh empty one.
Three more shared rules, each cited to `cli.rs`. `short_circuit` runs first: `--help` and
`--version` are answered on stdout with exit 0 before logging is initialised, before a store is
opened, before any network call — one bin's `--help` used to perform the entire ingest. `log_config`
sets every batch bin's file-log level to `warn` rather than vike-log's `trace` default, and the
measurement behind it is in the same doc: one hour of `pmxt_backfill` wrote 6.4 GB, and a long range
once wrote 341 GB. `VIKE_LOG_FILE_LEVEL` still wins over that default; [log volume](/docs/trader/data-providers/log-volume)
carries the rule for any long run. And every value flag refuses a token beginning with `--` as its
value (`is_flag_token`), so a mistyped command names the flag that went unfed rather than swallowing
the next one.
## Reaching a collector without a terminal [#reaching-a-collector-without-a-terminal]
Two crates depend back on vike-backfill through optional features (`lib.rs` header): the desktop
app — which took it under the deleted `fat` feature and calls nothing directly any more, its bulk
Backfill action now taking only `backfill_route`'s WIRE arm against the datahub's store — and
`vike-datahub`'s `backfill-serve` feature for backfill-on-demand. [Backfill from the
GUI](/docs/trader/gui/data-manager/backfill) covers the first; the store layouts every family writes into
are on [the hist store](/docs/trader/concepts/hist-store) and, kind by kind, under
[concepts/data](/docs/trader/concepts/data).
# Log volume (/docs/trader/data-providers/log-volume)
Every Vike Trader binary logs through `vike-log`: a console layer on stderr and a non-blocking JSON daily-rolling file layer. The two are filtered independently, and that independence is the whole hazard: `RUST_LOG` (aliased `VIKE_LOG`) filters the **console only**. The file layer defaults to `trace` and captures the `log`→`tracing` bridge, so a batch tool driving a chatty dependency — DataFusion, arrow, hyper — writes an enormous JSON file that no console setting can turn down.
`crates/vike-log/src/lib.rs`'s `file_level_directive` records the cost: one hour of `pmxt_backfill` wrote **6.4 GB**, and a long range once wrote **341 GB** and nearly filled the disk hosting a live trading node. The operator's half does not follow from the knob — set it, and on a long run still watch the log directory grow with `du -sh $VIKE_LOG_DIR`.
## `VIKE_LOG_FILE_LEVEL` [#vike_log_file_level]
The file layer's directive is `VIKE_LOG_FILE_LEVEL` first, then the configured `file_level`. `warn` keeps the file useful without the per-row firehose; `off` silences it entirely. An empty or whitespace value falls through to the configuration rather than widening the filter. The registry row in `vike_ops::settings::SETTINGS` lists the default as `trace`; the variable is one of the environment reads catalogued on [environment variables](/docs/trader/reference/environment).
Three places set the configured half:
* **The backfill bins.** `crates/vike-backfill/src/cli.rs`'s `log_config` builds every batch bin's `LogConfig` with `file_level` at `warn` instead of vike-log's `trace`, because these bins run long and unattended. `VIKE_LOG_FILE_LEVEL` still wins, so a one-off debug run can ask for `trace` without a code change.
* **The daemon and the desktop app.** `preferences.log_file_level` in `preferences.toml` (see [preferences](/docs/trader/reference/settings/preferences)) is the file-level knob for `vike-tradehub` and `vike-desktop`: `vike_config::CONSUMPTION` cites `crates/vike-tradehub/src/tradehub_cli.rs` as its consumer and notes `vike-desktop`'s `main` sets it identically. `crates/vike-config/src/preferences.rs`'s `DEFAULT_LOG_FILE_LEVEL` keeps it at `trace` so the crate changes nothing by itself; the field exists so a deployment can turn it down in a file instead of an environment variable. `preferences.log_level` is the console twin.
* **Everything else** inherits `LogConfig::default()`: console `info`, file `trace`.
`vike-tradehub` can apply a changed level without a restart: `vike_log::LogReloadHandles` recomposes the directive through the same pure functions `init` used, so a reload can never produce a filter a restart would not. `reload_file_level` returns an error when no file layer is installed, because a level applied to a layer that does not exist has been applied to nothing.
## Retention is the disk bound; the level is the write-rate bound [#retention-is-the-disk-bound-the-level-is-the-write-rate-bound]
Turning the level down bounds how much is written; nothing bounds how much is kept unless something deletes files. `LogConfig::file_max_files` — default `vike_log::DEFAULT_MAX_LOG_FILES`, which is 3 — prunes to the newest N daily files at `init`, so the count is a retention in days. Three is chosen against the failure it is for: a fault is diagnosed from the run that failed and the one before it. `None` restores keep-everything. It is deliberately a `LogConfig` field and not an environment variable: a retention only an operator can arm is a retention nobody has armed, which is exactly what `VIKE_LOG_FILE_LEVEL` had been.
Pruning matches `filename.starts_with(prefix)`, which is why `LogConfig::file_prefix` now defaults to empty, meaning "derive from this executable" (`effective_file_prefix`). The old shared default `"vike"` starts both `vike-desktop.` and `vike-tradehub.` in the one shared log directory, so a one-shot backfill would have deleted the GUI's and the live daemon's logs.
## The desktop client is its own firehose [#the-desktop-client-is-its-own-firehose]
A render loop logs at frame rate. `HIGH_VOLUME_TARGETS` pins `wgpu_core`, `wgpu_hal`, `naga` and the `eframe::native` subtree to `info` in the file, and its doc carries the measurement: a freshly installed Windows desktop client with no `VIKE_LOG_FILE_LEVEL` set wrote **5.8 GB in 4h10m** — about 1.4 GB an hour, roughly 11 GB overnight — of which Vike's own lines numbered about twenty-five; the rest was `wgpu_core` and `eframe::native`, tens of lines per frame. Retention cannot help there, because one day *is* the 5.8 GB. The pins keep wgpu's one-time adapter and device lines, which a GPU bug report needs, and drop the per-frame lifecycle. An explicit mention wins — `VIKE_LOG_FILE_LEVEL=wgpu_core=trace` restores the firehose when the renderer is what you are debugging — and a pin may only ever narrow: it is never appended to a directive already at `off`, `error` or `warn`.
`CREDENTIAL_BEARING_TARGETS` pins `ureq` and `tungstenite` to `info` even under `trace`, and this is not about volume. `ureq` prints a request's path and query only at trace — where a Telegram bot token sits in the URL path and other providers' API keys sit in query parameters — and `tungstenite` logs the full WebSocket URI at debug, including Binance's listenKey. The pins survive an operator-set `trace`; `VIKE_LOG_FILE_LEVEL=ureq=trace` is the typed-out opt-in. It is a denylist, so a newly added dependency that logs URLs would leak again.
## Where the file lands [#where-the-file-lands]
`vike_log::resolve_log_dir` has four layers, highest first: `$VIKE_LOG_DIR`; then a config file's `log_dir` or a `--log-dir` flag; then the project's own `/settings/state/logs`, which every shipped binary passes; then `/logs` as the last resort for a binary with no project above its working directory — `target/debug/logs/…` in a checkout, which `cargo clean` deletes. The file layer is best-effort: a destination that cannot be opened drops the layer with one line on stderr naming the directory and `VIKE_LOG_DIR`, and the console carries on. It used to panic the process with exit 101 for the sole reason that it could not open a log file; `crates/vike-log/tests/unwritable_log_dir.rs` is the gate.
The rule to carry: **set `VIKE_LOG_FILE_LEVEL` for any long backfill or replay run**, check the retention is armed, and treat a Vike-owned line count of twenty-five in a multi-gigabyte file as the signature of a missing pin rather than a busy system.
# Polymarket (/docs/trader/data-providers/polymarket)
`crates/vike-backfill/src/caps.rs` is unambiguous about this venue: verified rather than assumed,
the bridge implements no price-history endpoint — no `/prices-history`, no fidelity parameter — and
no L2 book history exists to fetch, so the venue row is empty across every kind. Every Polymarket
fill comes from an archive or a capture. Two collectors do that; two research bins read the same
sources in place, with no import step at all.
## `pmxt_backfill` — the pmxt archive [#pmxt_backfill--the-pmxt-archive]
The data source is the pmxt Polymarket order-book archive, licensed CC BY 4.0, published as
Parquet by pmxt (`r2v2.pmxt.dev`), which is not affiliated with this project — the attribution the
module carries in its own header (`crates/vike-backfill/src/pmxt/mod.rs`). The bin is a default
target: no feature is needed.
```
pmxt_backfill --from YYYY-MM-DDTHH --to YYYY-MM-DDTHH [--tokens T1,T2,...] [--tokens-file PATH]
[--store DIR] [--dry-run] [--rate-ms N]
[--clickhouse [--ch-bin BIN] [--min-local-ts MS] [--max-local-ts MS]]
```
From the bin header (`crates/vike-backfill/src/bin/pmxt_backfill.rs`): it iterates every UTC hour
in the inclusive range; for each it downloads that hour's Parquet part (a 404 means pmxt has not
published it — logged and skipped), decodes it a row group at a time (parts run 130–400 MB, so the
memory bound is the point), and appends the book, trade and quote events to the store. One bad
hour never aborts the range. `--tokens` and `--tokens-file` (one id per line, `#` comments allowed)
union into a filter — a real run's \~78k ids exceed the argv limit, hence the file. `--dry-run`
prints the hour range, the store root and each archive URL and touches nothing. The destination is
visible in `crates/vike-backfill/src/pmxt/ingest.rs`'s `ingest_file`, which appends under
`venue=polymarket` with the token id as the symbol: `kind=book` (snapshots plus deltas),
`kind=trade` and `kind=quote` (the venue's own `best_bid`/`best_ask` carried on `price_change`
rows — prices only, and per `crates/vike-backfill/src/pmxt/map.rs`'s `l1_from_row` the archive's
only complete record of the top of book, without which a delta replay is measurably wrong), the
same series shape the live feed writes, each under a `pmxt:{kind}:{asset}:{hour}` commit key — so
its doc can say re-runs of the same hour are a no-op. The pure mapper
(`crates/vike-backfill/src/pmxt/map.rs`) folds `book` rows to snapshots, `price_change` to
single-level deltas and `last_trade_price` to trades; `tick_size_change` updates per-asset state
that rides on the next book update, defaulting to `0.01` until one is seen.
`--clickhouse` switches the target from the hist store to the `polymarket` ClickHouse tables the
live L2 recorder writes, via `clickhouse-client` INSERTs. Unlike the hist-store path, re-running the
same hour duplicates every row — track completed ranges yourself
(`crates/vike-backfill/src/pmxt/ingest.rs`'s `ingest_file_clickhouse`). `--min-local-ts`/
`--max-local-ts` exist for that reason: a recorder outage rarely starts on an hour boundary, so
pass the outage's actual `[start, end]` in epoch-ms and only rows inside it are written, while every
fetched hour is still decoded in full.
## `poly_reparse` — your own raw frames [#poly_reparse--your-own-raw-frames]
The bridge owns a raw-frame tap: `RawTap`/`TappedStream` tee verbatim WS frames plus the local
receive time to gzip files (`Feeds::with_raw_capture`, opt-in).
`crates/bridges/polymarket/src/raw_tap.rs`'s header states its disposition — raw capture is
strictly insurance, losing a frame is acceptable and affecting the live feed is not — and
`poly_reparse` is what re-parses that perishable capture back into the store.
`crates/vike-backfill/src/reparse.rs`'s `GzFileStream` replays those files as a
`vike_polymarket::MarketStream`, so `run_session` re-decodes them through the *same* pump as a live
session, and the captured receive time is restored as `local_ts`. The layout mirrors the writer:
`/polymarket//date=YYYY-MM-DD/frames-*.jsonl.gz` (`gz_files_for`).
```
poly_reparse --raw-dir DIR --store DIR --token ID --from YYYY-MM-DD --to YYYY-MM-DD \
--mode book|quotes|trades --tick-size F
```
`--tick-size` must match the value the live session used, or the book quantizes on a different
grid; the operator supplies it, because this offline tool makes no REST fetch (bin header). The bin
is built under the `poly-reparse` feature, which enables `vike-polymarket/feeds`
(`crates/vike-backfill/Cargo.toml`).
`archive_store.rs` notes the convention for whoever reconciles this store with another: it scopes a
range by `ts`, the venue clock, exactly as `DataFusionHist` does.
The archive download itself, and the events API, are on
[the Data API page](/docs/trader/data-providers/vike-data-api). Running the venue live is
[Polymarket operations](/docs/trader/venues/polymarket-operations); the queue models are under
[backtesting](/docs/trader/backtesting/queue-models) and the maker models under
[market making](/docs/trader/market-making).
# Store maintenance (/docs/trader/data-providers/store-maintenance)
Two `vike-backfill` bins maintain a history store rather than fill it from a source. The supervisor that keeps the collectors themselves running is a separate binary, documented at [collector-supervisor](/docs/trader/reference/binaries/collector-supervisor).
## Where the store is [#where-the-store-is]
Most bins in the crate resolve their root through `crates/vike-backfill/src/cli.rs`'s `store_root`, with one precedence: an explicit `--store`, then `$VIKE_HIST_STORE`, then the project's own `/market_data/hist` when `$VIKE_SETTINGS_DIR` names that project outright, then the repo-root default `/market_data/hist` when that checkout exists on the box, then the `/market_data/hist` found by walking up from the working directory, then the per-user `vike-data` directory. The resolution is logged — root and rung both — for a reason its doc states: a store does not merge. If the default moves, the old store is simply no longer read, and a backfill reports zero rows into a fresh empty one, indistinguishable from "the venue returned nothing" until somebody goes looking. `$VIKE_SETTINGS_DIR` is the override that pins the project, and the same resolver honours it.
## `migrate_to_group` [#migrate_to_group]
```
migrate_to_group --venue NAME [--store DIR] [--group NAME] [--dry-run]
```
Tick series have two layouts — one series per symbol, or one grouped series per family — and this bin folds stray per-symbol series into their family's grouped one. `crates/vike-backfill/src/bin/migrate_to_group.rs` names the two real causes of a store holding the same instrument in both layouts: a family recorded before grouping existed, and two recorder bugs, since fixed, that let rows escape (a max-rows flush that skipped the group resolver, and a rotation that unmapped a symbol while its rows were still buffered).
Such a store is not wrong: `scan_*` reads both layouts, so no row is lost or hidden. It is untidy in ways that cost later — the coverage report in the [Data Manager](/docs/trader/gui/data-manager) lists one instrument twice, maintenance treats them as unrelated series, and every scan opens both.
The store cannot know which family a stray token belonged to — that knowledge lived in the recorder's in-memory membership map and was never persisted. So a fold happens only when the venue has **exactly one** grouped series for that kind. With none there is nothing to fold into; with several, picking one would write rows into the wrong family, which no later pass can untangle. `--group NAME` names the target explicitly for the several case.
`crates/vike-backfill/src/regroup.rs`'s `plan_regroup` is the pure half: per kind it yields `KindPlan::Fold`, `NoTarget` or `Ambiguous`, and kinds with no strays are absent. `GROUPABLE_KINDS` is `quote`, `trade`, `book` and `depth`; `bar` has no grouped append and never folds. A forced `--group` overrides the target for every kind, including a kind that had no group at all, which is what lets a fold into a brand-new group name work.
The I/O is `vike_data::DataFusionHist::migrate_series_to_group`, and its order is deliberate: copy, verify, then delete. Rows are appended under a `migrate:{kind}:{symbol}` commit key, so re-running is a no-op rather than a double-append; the grouped read is checked to contain them; only then is the per-symbol source removed. A failure anywhere before that leaves both copies — untidy, which is what it already was, never missing. One series failing never aborts the rest; the run prints series planned, rows moved and series skipped. `--dry-run` prints the plan and writes nothing.
## `ingest_bench_bars` [#ingest_bench_bars]
```
ingest_bench_bars
```
The seed for the two-engine benchmark, `cargo bench -p vike-backtest --features bench-hist`. It ingests a Python parquet bar cache into a self-contained bench store — the pure-Rust replacement for a retired Python exporter, with no venv, pandas or SQLite — and is idempotent by commit key (`bench:{sym}:{month}`), so a re-run is a no-op.
It takes no options; both paths are fixed at compile time. The source is `/storage/parquet//1m/.parquet` with columns `ts,open,high,low,close,volume`; the destination is `/market_data/bench_hist`, written as `kind=bar`, `venue=binance`, interval `1m`, and derived from the crate directory at compile time so the ingest writes into its own checkout from any worktree rather than a hardcoded absolute path. `SYMBOLS` is the five-symbol canonical order (`BTCUSDT`, `ETHUSDT`, `SOLUSDT`, `DOGEUSDT`, `ADAUSDT`; BTCUSDT doubles as the single-symbol bench series) and `MONTH` is `2026-06`.
This bin once read no argv at all, so `ingest_bench_bars --help` opened a store, created a directory and wrote bars — the worst shape in the family, because `--help` is the safest-looking thing a person types first. The triage now runs before the log subscriber and before the store, and `crates/vike-backfill/tests/help_cli.rs` asserts both the exit status and that no store directory appeared. `crates/vike-backfill/src/cli.rs`'s `short_circuit` generalises the rule in its own doc — call it first, before `vike_log::init`, before opening a store, before any network call, because answering a question about the command line must not create a log file, create a store directory, spawn a subprocess or ingest anything.
Both bins log through the crate's shared `log_config`, which caps the JSON file at `warn`; see [Log volume](/docs/trader/data-providers/log-volume).
# Tardis (/docs/trader/data-providers/tardis)
`tardis_backfill` ingests Tardis's downloadable CSV datasets. `crates/vike-backfill/src/tardis/mod.rs`
states the lane: intraday trades, quotes and the L2 book — and bars via resample — from
`datasets.tardis.dev` into the `vike-data` hist store, a sibling of the pmxt and EOD collectors. It
is built only under the `tardis` feature (`crates/vike-backfill/Cargo.toml`), so the invocation is
`cargo run -p vike-backfill --features tardis --bin tardis_backfill`.
## What a run does [#what-a-run-does]
From `crates/vike-backfill/src/bin/tardis_backfill.rs`'s usage text:
```
tardis_backfill --exchange deribit --symbol BTC-PERPETUAL --data-type trades \
--start 2024-03-01 --end 2024-03-03 [--store DIR] [--venue NAME]
tardis_backfill --exchange deribit --symbol BTC-PERPETUAL --data-type bars \
--start 2024-03-01 --end 2024-03-03 --interval 5m
```
`--exchange` is the Tardis exchange id (`binance-futures`, `deribit`), `--symbol` the instrument on
that exchange, and `--data-type` one of `trades`, `quotes`, `incremental_book_L2` or `bars`.
`--start`/`--end` are inclusive `YYYY-MM-DD` days. `--store` resolves through the shared
`store_root` ladder — `$VIKE_HIST_STORE`, then a `$VIKE_SETTINGS_DIR`-declared
`/market_data/hist`, then `/market_data/hist` when that checkout exists, then the
project the working directory sits in, then the per-user `vike-data` directory; `--venue` is the
label the series is stored under, defaulting to `tardis`.
`bars` is not something the vendor serves. The bin header is explicit: Tardis has no native OHLCV
series, so `--data-type bars` ingests trades and then resamples them into `--interval` OHLCV
(default `1m`) — real OHLCV with real trade volume — and the trades series is persisted too. The
resample is the store's own `resample_trades_to_bars`, called from the bin.
## Transport and ingest [#transport-and-ingest]
`crates/vike-backfill/src/tardis/client.rs` builds the per-day `.csv.gz` URL, authenticates with a
Bearer token, and downloads plus gzip-decompresses one day's file. A missing day — HTTP 404 — is
`Ok(None)`, not an error, mirroring the pmxt collector's `download_hour`.
`crates/vike-backfill/src/tardis/ingest.rs` drives the parsers one day at a time under the commit
key `tardis:{kind}:{symbol}:{YYYY-MM-DD}`; `backfill_range` iterates the requested days and skips the
absent ones. Because the unit is a whole UTC day, re-running a day is a store no-op.
## The parsers [#the-parsers]
`crates/vike-backfill/src/tardis/parse.rs` is pure and fixture-tested, and its header carries the
wire facts: timestamps are microseconds, reduced to milliseconds; trade `side` is `buy`/`sell`,
where a sell taker means the buyer is the maker. `incremental_book_L2` rows are single-level —
`is_snapshot` selects Snapshot over Delta, `side` routes the level to bids or asks, and `amount` is
the absolute size with `0` meaning the level was removed. A monotonic per-symbol `seq` is assigned in
file order so that `scan_book_updates` reproduces file order. Column lookup is by name through
`crates/vike-backfill/src/csvutil.rs`'s `Header`, the helper shared with the Databento parsers.
## Credentials [#credentials]
`TARDIS_API_KEY` is optional, and the reason is in the bin header: the first-of-month day is a free,
keyless sample, so a run over that day needs no key at all. The bin reads the key and passes it in;
the library takes it as a parameter (`crates/vike-backfill/src/databento/client.rs`'s header records
that the Tardis adapter always had the bin do the read, and that Databento was brought in line with
it). The live smoke is `#[ignore]`d and runs keyless for exactly that reason — it asks for the
first-of-month day (`crates/vike-backfill/tests/tardis_smoke.rs`'s
`tardis_trades_one_day_roundtrip`). Its Databento sibling has no free day to fall back on, so that
one self-skips when the key is absent (`crates/vike-backfill/tests/databento_smoke.rs`).
## Where it sits in the capability table [#where-it-sits-in-the-capability-table]
`crates/vike-backfill/src/caps.rs`'s Tardis row is `NONE` for every roster venue: the comment on it
says the vendor writes the tick lane — trades, quotes, `incremental_book_L2` — with bars by
resample, is crypto-only, and is vendor-namespaced rather than roster-keyed, the same shape as
Databento. A gap plan over a roster venue therefore never offers Tardis; you reach it by naming the
exchange and symbol yourself, and the `--venue` label decides which store partition the rows join.
`--exchange binance-futures` chooses what Tardis serves; `--venue` (default `tardis`) chooses the
store partition. Writing vendor rows under a roster venue's name puts them beside whatever the
recorder captured there, and the store dedups by commit key rather than by row value
(`crates/vike-data/src/datafusion_hist/sources.rs`), so a window both covered is doubled until a
source-precedence policy resolves it. [The Data API page](/docs/trader/data-providers/vike-data-api)
covers that policy.
The kinds these data types write are documented under [concepts/data](/docs/trader/concepts/data); the
equities-and-futures counterpart is [Databento](/docs/trader/data-providers/databento).
# Venue-direct (/docs/trader/data-providers/venue-direct)
Seven bins in `crates/vike-backfill` talk to a venue directly, and all seven sit behind the
`venue-backfill` feature (`crates/vike-backfill/Cargo.toml`): `binance_backfill`,
`bybit_backfill`, `okx_backfill`, `aster_backfill`, `deribit_backfill`, `dukascopy_backfill` and
`hyperliquid_backfill`. A default build has neither the module nor the bridge dependency, so the
invocation is `cargo run -p vike-backfill --features venue-backfill --bin `. What they have
in common, from `crates/vike-backfill/src/caps.rs`: every one of them writes `kind=bar`, and
Dukascopy is the only venue-direct source that also serves a tick kind — quotes, never trades,
because `.bi5` is a quote feed. None of them can repair a recorded trade or book gap.
## The five klines bins [#the-five-klines-bins]
`binance`, `bybit`, `okx`, `aster` and `deribit` differ in nothing but the venue.
`crates/vike-backfill/src/klines.rs` is the one place their shape lives: `commit_key` is the
idempotency format, `backfill_klines` the fetch→ingest orchestration each venue binds with its own
`VENUE` constant and bridge fetcher, and `run_klines_backfill_cli` the CLI body every bin calls.
Its `klines_usage` text is what `--help` prints, and it is exact about the arguments:
```
_backfill
```
All five are required and positional, in that order, and the window is in epoch
**milliseconds** — the usage names seconds as the mistake that silently backfills 1970. Bars land
under `kind=bar/venue={venue}/symbol=SYMBOL/interval=INTERVAL`, re-running the same window writes
0 rows, and a still-forming final candle is dropped. The `crates/vike-backfill/src/lib.rs` header
gives the key's shape: `{venue}:{symbol}:{interval}:{start}-{end}`. There is no resample step —
klines are already bars.
Per venue, from each bin's own header:
* **Binance** (`crates/vike-backfill/src/bin/binance_backfill.rs`) pages the public REST endpoint
at 1000 rows per request. It is the one bin of the family whose persisted pace actually
steers, because Binance is the only venue of the five that publishes a `REQUEST_WEIGHT` budget
for the pager to discover; the record is keyed `(binance, spot|perp)` because the two hosts
publish different budgets (6000 vs 2400 weight per minute) and price `/klines` differently
(weight 2 vs 5).
* **Bybit** (`bybit_backfill.rs`) pages 1000 rows per request, newest-first and reversed by the
fetcher. `crates/vike-backfill/src/bybit.rs` warns that the endpoint is end-anchored — it
ignores `start` once the window exceeds the cap and answers the newest 1000 rows with
`retCode: 0` — so the bridge pages backward and a window wider than 1000 intervals is normal
here, never silently clipped.
* **OKX** (`okx_backfill.rs`) pages the `history-candles` endpoint backward through its
100-row cap via the `after` cursor. Its per-page cost was measured on prod2 on 2026-08-04 at
476–486 ms, against a 200 ms delay nobody had ever checked.
* **Aster** (`aster_backfill.rs`) reads keyless public mainnet REST; a symbol carrying the `.P`
suffix (`BTCUSDT.P`) fetches the USDⓈ-M perpetual and is stored under that suffixed key, a bare
symbol fetches spot. `crates/vike-backfill/src/aster.rs` says why mainnet is not a risk decision
here: klines are a keyless public read, so the standing rule to exclude Aster from validation
runs — which is about authenticated account calls — does not reach this path.
* **Deribit** (`deribit_backfill.rs`) pages the venue's public, keyless chart-data JSON-RPC
endpoint. `SYMBOL` is a Deribit instrument name passed through verbatim (`BTC-PERPETUAL`, a
dated future) — there is no `.P` suffix on this venue, because the name is already
unambiguous. The endpoint is end-anchored like Bybit's and is paged backward.
`deribit_backfill.rs`'s header: set `VIKE_LOG_FILE_LEVEL=warn` for it — the file layer is `trace`
by default and does not listen to `RUST_LOG`. The rule for every long run is on
[log volume](/docs/trader/data-providers/log-volume).
## The pace book [#the-pace-book]
`crates/vike-backfill/src/pace_book.rs` explains what the klines bins persist and why: every
backfill measures its own pace — the wall clock of a request, the weight one request costs — and
used to discard it at exit, so run N+1 opened on a pessimistic constant (on Binance spot, where a
page costs weight 2, the pacer's seed of 5 paced the first pages 2.5× slower than the venue
permits). The record is written down and the next run reads it. `run_klines_backfill_cli` resolves
the file through `crates/vike-backfill/src/cli.rs`'s `pace_book_path`: `$VIKE_PACE_BOOK`, then
`/settings/state/pace.json`, then `/pace.json` for a binary with no project
above its working directory.
The file is a cache and follows a never-brick law: a missing, unreadable or unparseable file is
"no record", deleting it costs one run's opening pages and nothing else. And a record does not mean
a faster backfill. `run_klines_backfill_cli`'s doc is blunt: Binance's genuinely re-paces the first
page, while Bybit, OKX and Deribit's pagers answer their hardcoded `PAGE_DELAY` whatever is stored
— for them the record buys an ETA line from page zero and an operator-readable per-page cost.
Aster is the one bin that persists nothing at all, because its host is environment-resolved and the
bridge exposes no paced fetch to plumb a sample through (`aster_backfill.rs`).
## Dukascopy ticks [#dukascopy-ticks]
`crates/vike-backfill/src/bin/dukascopy_backfill.rs` takes a different argument shape:
```
dukascopy_backfill [INTERVAL]
```
It fetches tick history for a symbol such as `EURUSD` from Dukascopy's keyless `.bi5` datafeed,
appends the quotes, then — if `INTERVAL` is given — resamples the stored quotes into OHLCV bars.
`crates/vike-backfill/src/dukascopy.rs` names the pipeline: `fetch_ticks_range` → `tick_to_quote` →
`append_quotes` → `resample_quotes_to_bars`, the last being the parity-tested
`vike_model::consolidate_quotes`. It is history-only: Dukascopy publishes with a T+1 lag. This was
the crate's first collector, and `crates/vike-backfill/src/lib.rs`'s header still introduces it that
way — the first collector, keyless `.bi5` tick history to `QuoteTick`s to the store, plus a
quote-to-bar resample.
## Hyperliquid candles [#hyperliquid-candles]
`crates/vike-backfill/src/bin/hyperliquid_backfill.rs` pages the keyless `candleSnapshot` `/info`
endpoint on mainnet with named flags rather than positionals: `--symbols` is a comma list of
`vikeSymbol=coin` pairs (bare `SYM` means coin equals symbol — HL perps use the coin as the symbol,
`BTC`/`HYPE`), `--interval` is one of `1d`, `1h`, `5m`, `1m`, and `--start`/`--end` are each an
epoch-ms integer or a `YYYY-MM-DD` UTC date with `--end` defaulting to now. The fetcher pages
forward by advancing `startTime` past each page's last candle close
(`crates/vike-backfill/src/hyperliquid.rs`), and the one wrinkle is that the store symbol may
differ from the coin the fetch pages, so the commit key and append key on the symbol. Series land
as `kind=bar`/`venue=hyperliquid`, idempotent per window. The same module owns the realized-funding
backfill, which is on [funding rates](/docs/trader/data-providers/funding-rates).
The klines trio `backfill_{binance,bybit,okx}_klines` is also what the desktop app's bulk Backfill
action and `vike-datahub`'s backfill-on-demand call (`lib.rs` header) — see [Backfill from the
GUI](/docs/trader/gui/data-manager/backfill).
# The vike.io Data API (/docs/trader/data-providers/vike-data-api)
`data.vike.io` is one vendor reached two ways, and the two are different tools with different
features, keys and store kinds. `vikedata_backfill` reads the `/v1` REST API — cohort metrics and
the hourly asset panel — behind the `vikedata` feature. `vike_archive_backfill` reads the Polymarket
L2 Parquet archive behind the `vike-archive` feature. `crates/vike-backfill/Cargo.toml` explains why
they were deliberately not folded into one feature: the archive lane pulls a ranged-HTTP Parquet
reader and a worker pool that a cohort-metrics build would compile for nothing.
## Route one: `vikedata_backfill` and the `/v1` API [#route-one-vikedata_backfill-and-the-v1-api]
`crates/vike-backfill/src/vikedata/mod.rs` describes what it fetches: one
`(exchange, asset, axis, grading)` ladder over one hourly window from
`GET {base}/{exchange}/coins/{asset}/cohort-metrics`, paged by cursor — for each hour and each
cohort label, the long-side and total notional the grading service reported — landing as
`kind=cohort` rows through `vike_data::CohortRecorder`. The module builds no commit key of its own:
`crates/vike-data/src/cohort_rec.rs`'s `CohortFetch` is the one place
`cohort:{venue}:{asset}:{axis}:{grading}:{label_basis}:{start}-{end}` is spelled.
The bin header (`crates/vike-backfill/src/bin/vikedata_backfill.rs`) defines three run shapes, and
which one a timer may use:
```
vikedata_backfill --asset BTC --schedule --catch-up 3 # SCHEDULED — the only timer shape
vikedata_backfill --asset BTC --axis size --days 30 # periodic — MANUAL ONLY
vikedata_backfill --asset BTC --axis pnl --grading unrealized --start 2026-04-01 --end 2026-04-08
```
`--axis` is `size`, `pnl` or `tier` (default `size`); `--grading` is `realized`, `realized-pit` or
`unrealized` and is refused off the `pnl` axis; `--exchange` defaults to `hyperliquid`; `--api-base`
defaults to `$VIKE_API_BASE`, else `https://data.vike.io/v1`. `--panel` switches to the hourly asset
panel (`/v1/{exchange}/assets/hourly`) and writes `kind=perp_metrics` rows — `(ts, premium,
open_interest)` per hour — under `perp_panel:{venue}:{asset}:{start}-{end}`; the usage text names it
the only producer that fills `open_interest`, and
`crates/vike-backfill/src/vikedata/panel.rs` states why: the venue's own surface serves OI as a
current snapshot and nothing else.
`crates/vike-backfill/src/vikedata/schedule.rs`: idempotency is batch-level on the commit key, so
re-running the same command writes 0 rows, but two overlapping windows are two keys and every hour
they share lands twice. Fire `--days 30` at 04:00 and again at 05:00 and you have 719 hours of
doubled notional in a series whose whole purpose is a ratio. `--schedule` is the shape that cannot
express it: whole complete UTC days off a fixed grid, only for days that have ended, so two firings
inside one day emit identical windows and two days emit adjacent ones. `--catch-up N` re-attempts
the preceding days (default 1, max 31 — every extra day is a metered request on every firing).
The guards are the value, and `vikedata/mod.rs` lists them as ported rules: `label_basis` must
agree across every page and an absent one is a hard failure on the `pnl` axis (the endpoint's
default resolves a wallet's cohort at query time, which is lookahead, and it shipped once); the
grading echo must match what was asked; a `(ts, cohort)` seen twice across a page overlap is
first-wins; the cursor walk has three stops, including a window break, because the endpoint keeps
serving up to its own now regardless of the `start` sent; both notionals are snapped before
anything is derived from them (measured: 430 of 8,190 rows differing at \~1.4 ULP between two GETs of
a byte-identical URL); and the hour label is multiplied from seconds to milliseconds, because an
unmultiplied row lands in the 1970 partition for good.
The header also says what is measured and what is not. Against the live endpoint on 2026-08-25, the
`pnl` `label_basis` echo is real and the auth header is case-insensitive; a `--axis size --days 1`
run stored 300 rows. Still unmeasured: the `pnl` and `tier` axes end to end, and any cursor walk past
one page.
`VIKE_API_KEY` comes from the credential store and is sent as an `X-API-KEY` header
(`crates/vike-backfill/src/vikedata/client.rs`). Absent is a refusal, not a fallback: an
unauthenticated fetch 403s part-way through a window and would store a partial batch under a key
claiming the whole one, indistinguishable from a thin tape afterwards (bin header).
## Route two: `vike_archive_backfill` and the Parquet archive [#route-two-vike_archive_backfill-and-the-parquet-archive]
`crates/vike-backfill/src/vike_archive.rs` describes the archive: Polymarket full L2
(`book_events`), taker tape (`trades`) and derived top-of-book (`l1_quotes`), one UTC day per
Hive-style partition at `{base}/venue=polymarket/date=YYYY-MM-DD/{stream}.parquet`, plus an open
`manifest.json` needing no key. Auth is an `X-API-Key` header; the bin reads
`VIKE_ARCHIVE_API_KEY` from `/settings/secrets.env` and passes it in as a parameter. The
sizes are the vendor's own: a `book_events` day runs \~7.4 GB, 465M rows, 453 row groups.
```
vike_archive_backfill --from YYYY-MM-DD --to YYYY-MM-DD [--kind book|trade|quote|all]
[--tokens T1,T2,...] [--tokens-file PATH] [--store DIR] [--dry-run]
[--asset NAME --tenor NAME] [--bulk ...] [--file PATH [--jobs N]]
```
The module's differentiator is that it does not need the whole file: `HttpRangeReader` implements
the `parquet` crate's `ChunkReader` over ranged GETs, so the reader pulls the footer (\~1.17 MB in
two range reads) and then only the column chunks of row groups whose `token_id` min/max statistics
bracket a requested token (`select_row_groups`). A filtered single-token scan costs one row group —
tens of MB — rather than the day. Ingest streams one row group at a time and commits under
`vikearchive:{kind}:{date}:rg{idx}`, a per-row-group key that makes a partial run resumable.
The bin header (`crates/vike-backfill/src/bin/vike_archive_backfill.rs`) then records where the time
actually goes. A `book_events` day costs \~419 row groups × one HTTP round trip each through the
CDN — hundreds of seconds of network latency, not compute. **`--file PATH`** is the measured fix:
download the day file once, then ingest it locally through the identical decode/map/commit pipeline
(`LocalFileReader` in place of `HttpRangeReader`), committing byte-identical rows under the same
keys. `--file` needs an unambiguous target — `--from` equal to `--to`, and an explicit `--kind` —
and it is mutually exclusive with `--asset`/`--tenor`, the pair that switches to the family layout
(`venue=polymarket/asset=/tenor=/date=.../{stream}.parquet`, resolved through the
`/v1/archive/datasets` and `/v1/archive/url` discovery calls). `--jobs N` fans the local ingest
across worker threads (default `min(4, available_parallelism)`), and `--bulk` switches to
`vike_data::BulkIngestSession`'s staged commit profile (defaults 20 row groups / 2M rows / 256 MB
per window). The two compose.
Bulk idempotency is by commit key, and the keys embed a chunk index that is a function of the
selected row groups and the job count. Re-running a crashed `--jobs 4` import as `--jobs 8`
re-chunks, mints different keys, and rewrites rows already committed — duplicates, not a no-op,
because the store never dedups by row value
(`crates/vike-backfill/src/bin/vike_archive_backfill.rs`'s header states the rule, and
`resolve_jobs` beside it carries the fan-out's own measurements).
Two siblings share this lane. `events_api_backfill` reads the live `/v1/events` endpoint under the
same feature and key and is on [EOD, events and exec trades](/docs/trader/data-providers/eod-and-events).
And `crates/vike-data/src/archive_store.rs`'s `ArchiveParquetHistStore` skips the import
entirely, answering `HistStore` reads straight off downloaded day files — the route
[the Polymarket page](/docs/trader/data-providers/polymarket) recommends for research.
## When two writers cover one window [#when-two-writers-cover-one-window]
`crates/vike-data/src/datafusion_hist/sources.rs` is where the store keeps the rule this vendor
makes necessary. Commit-key idempotency stops the same writer re-ingesting a window; it does nothing
about two different writers — the recorder's `live-…` key and a backfill's — covering one window,
and the store never dedups by row value, so a scan returns both copies. `StoreSourcePolicy` — the
ranked prefix list plus its `strict` flag, which builds the call-time `SourceRankPolicy` — is
persisted next to the data as `_sources.json` so `DataFusionHist::run_maintenance` can resolve
duplicates on its own, and the header lists what makes a row-dropping pass safe on a timer: an
absent policy file is byte-identical to today; an empty prefix list supersedes nothing; a series
whose commit key matches no listed prefix is skipped, with the key named, unless `strict: false` is
set; and every drop is reported through `rows_superseded`. It is not a claim about which source is
better — only which one this operator decided wins.
# Adding a venue (/docs/trader/developer/adding-a-venue)
Adding a venue touches a per-venue table in a couple of dozen files. Nobody knows how many, and that
is deliberate: **no count is written down anywhere**, because every hand copy of a roster in this
tree has rotted, and the brief that first asked for a scaffold listed nine such sites from memory and
was already far short. `just new-venue-sites` prints the derived set. `just new-venue NAME` fills it
in.
## The roster is one const, and it fails everything until you classify [#the-roster-is-one-const-and-it-fails-everything-until-you-classify]
`crates/vike-model/src/venues.rs`'s `VENUES` is the single canonical roster — the authority on
"every venue Vike Trader has a bridge crate for". Four per-venue capability tables had each grown
their own private copy of the venue list inside their rows and completeness tests, and those copies
had already drifted: one venue was absent from three of the four.
The contract now is that **every per-venue table's completeness test iterates `VENUES`**, so adding
an entry fails every table's tests until its rows land. Fallback arms stay for genuinely unknown
strings, but no roster venue may silently ride one: a roster venue's row is always *named*, even when
its value equals the fallback, because the named row is the declaration that the venue was
classified rather than forgotten.
## The scaffold, and why its work-list is derived [#the-scaffold-and-why-its-work-list-is-derived]
Each per-venue table declares its **own** insertion point, next to its own rows, with a marker
comment carrying its own template:
```text
// vike:new-venue:row "{venue}" => VenueCaps::…, // TODO(new-venue: {venue}): …
// vike:new-venue:note
```
The scaffold walks the tree, renders every template, and inserts it above its marker —
`crates/vike-bridge-core/src/tif.rs` and `crates/vike-bridge-core/src/pump_spec.rs` each carry one,
next to the rows it belongs to. The generator knows nothing about which tables exist, so a **new**
capability table joins the form by adding one marker line — no generator change.
`crates/vike-ops/tests/new_venue_gate.rs` supplies the missing half: an independent derivation of
which files *need* a marker, so a table cannot join the tree without joining the form. The scaffold
fills in the shape; the gate makes you fill in the content, because every generated row is tagged
and `no_scaffold_placeholder_survives_into_main` refuses the tag.
**How a row site is derived.** `row_sites` calls a `.rs` file a row site when it names all but at
most `ROW_SITE_MISS_TOLERANCE` of the ids in `VENUES` as quoted string literals — the actual
signature of a per-venue table, and nothing else in the tree names the whole roster. The tolerance
matters: requiring *every* id erased precisely the tables that were already missing a row, and
erased them *because* a venue had been added, which is the exact moment the form matters.
The tempting alternative — "a `#[test]` whose body iterates `VENUES`" — is measurably worse in both
directions. It over-matches on a test that iterates the roster only to mirror another table; and it
under-matches on a file whose roster loop asserts nothing per venue, so a new venue's missing row
there is invisible to a test-shape derivation and to the suite alike. Literals find it; failures do
not.
It answers "does this file carry a per-venue table", not "does this file go red". Three of the sites
it finds gate nothing at all for a new venue — a missing row there is a silent wrong answer, which is
strictly worse than a red test. A derivation keyed on redness would have skipped exactly those.
Structural files — the workspace `members` list chief among them — are not `.rs` and name no venue
literal, so they sit outside the derivation and are pinned by hand in `STRUCTURAL_SITES` with the
reason each is still checkable elsewhere. The local CI crate roster used to be one of them and no
longer is: it is derived, so a new venue joins the local gate the same way it joins the merge gate —
by being a workspace member. That is a step *deleted* from the form rather than automated.
A rendered row's *compilation* is deliberately not gated — that is the compiler's job, and the round
trip (`just new-venue foo` → `cargo check` → `just new-venue-remove foo`) is how a template change is
proven. One formatting trap *is* gated, because it defeats the scaffold rather than merely looking
bad: a generated row ending in a trailing `//` comment directly above a marker is rustfmt-unstable —
the formatter re-indents the marker onto the comment's column, the format check goes red on the
tool's own output, and the moved marker then breaks removal. Measured against the real formatter, a
following *statement* is not a reprieve; only a following comma-separated **element** is.
## The shared homes are consumed, never modified in passing [#the-shared-homes-are-consumed-never-modified-in-passing]
A venue conversion consumes three shared homes: the venue **family drivers**, the `vike-bridge-core`
pumps, and the per-venue capability tables. A missing knob in any of them is a **stop-and-report**,
not a local patch — the extension lands as one coordinated pull request informed by every consumer's
report.
The playbook for a behaviour divergence found across adapters is two steps, never one. **Step 1
declares today's reality**: a static capability map, one row per venue citing the adapter code it was
read from, a verbatim matrix pin, and a completeness test iterating `VENUES` — merged
byte-identical, with contradictions *pinned* rather than fixed. **Step 2 flips behaviour**, one venue
row at a time, behind demo smokes.
## What the new bridge must satisfy [#what-the-new-bridge-must-satisfy]
Two shared conformance harnesses in `crates/vike-bridge-core/tests/` machine-check the venue-adapter
contract, each carrying its own roster-exhaustiveness test over `VENUES` — so every roster venue is
either a covered implementation or a deferred-with-reason row, and a new venue reddens both until it
is classified. `bridge_conformance.rs` runs one scenario table against each covered venue's real
event mapper folded through the real order FSM, checking the emitter split, no-silent-vanish, and
exactly-one-terminal; `market_data_conformance.rs` is the market-data twin over each venue's real
frame router.
The contract those harnesses enforce is described on
[The ExecutionClient seam](/docs/trader/concepts/execution-client-seam) and
[Order lifecycle](/docs/trader/concepts/order-lifecycle); the per-venue pages live under
[Venues](/docs/trader/venues).
# Building (/docs/trader/developer/building)
Vike Trader is a Cargo workspace of small crates. A plain `cargo build --workspace` is a real build
of most of it — but not of all of it, and the gap is the first thing worth understanding before you
change anything.
## The roster is derived, never written down [#the-roster-is-derived-never-written-down]
CI does not read a hand-maintained list of crates. It computes one:
`sorted(names - EXCLUDE_FROM_CI)` over the workspace members `cargo metadata` reports. That is
`xtask/src/ci/mod.rs`'s `ci_crates`, and the consequence is the point — **a crate joins the merge
gate the moment it joins `[workspace].members`**, with nothing to edit to let it in.
`xtask/src/ci/tables.rs`'s `EXCLUDE_FROM_CI` is the whole of the exception, and each row carries its
reason: the vendored IBKR wire-protocol copy (exercised through its own feature lane); `vike-desktop`,
the egui/wgpu desktop binary, whose eframe + wgpu closure matches no other lane's cache and which
is instead checked, linted and unit-tested by its own dedicated job; and `vike-backfill`, which only
builds and tests under features. `xtask` itself is deliberately absent from that list — excluding
the tool that derives the merge gate would leave it as the one crate the merge gate never compiles.
## A default build is not the whole tree [#a-default-build-is-not-the-whole-tree]
Large parts of the workspace are behind off-by-default Cargo features, and the reason is usually
that a default build should not compile them: an order signer, a proprietary SDK shim, a remote
control channel, a DataFusion dependency. Each of those configurations gets a **feature lane**.
Two files own the lanes between them, and they own different halves.
`xtask/src/ci/tables.rs`'s `FEATURE_SUITES` decides which suite keys the plan may emit and which
crates trigger each one; the shell script CI runs decides, in its `case` arms, what each key
actually runs. `xtask/src/lib.rs`'s header states that split, and records that moving the lane
commands into the crate would be a separate change with its own gate to rewrite. The `features` job
wires the first straight into the second, so the two key sets are one roster written twice — which
is why they are gated against each other. Nearly every row there carries a written argument for the
coverage hole it closes, and `tables.rs` states the rule for editing it: a row there is not a
preference, it is a claim about what CI would otherwise fail to test.
What a lane compiles is decided by the `case` arms of the feature-suite shell script CI executes;
`xtask/src/lib.rs`'s header records exactly that split. That script ships with the private build
tooling rather than the published tree, so no summary of it — this page included — can be checked
against it. What you can open is the other half: `xtask/src/ci/tables.rs`'s `FEATURE_SUITES` names
every suite key and the crates that trigger it.
Treat a lane's crate list here as history, not as a spelling. Two lanes have already been widened,
and each widening is argued on its own trigger row in that table. The FXCM one twice: first when
the live mount arm landed behind vike-mount's own `fxcm` feature, then when the release grew an
FXCM-linked daemon asset. The IBKR one twice: first when the live-mount chain above the bridge
crate turned out to be compiled by nothing, then again when the daemon itself grew an `ibkr`
feature and the chain reached one crate further. Re-spelling a lane from a prose copy is how a
chain silently drops out of it.
## The one committed cargo config [#the-one-committed-cargo-config]
Two files configure the compiler for everyone who clones the repository: `.cargo/config.toml` and
the root `Cargo.toml`'s profile tables. Neither is anybody's code, both are read on every build, and
a change to either arrives without a call site to review it — which is why
`crates/vike-ops/tests/build_config_gate.rs` exists.
It reads **every** file cargo would honour, not the one at the root. Cargo merges `.cargo/config.toml`
— and the pre-1.39 extensionless `.cargo/config` — out of every ancestor directory of wherever it was
invoked, closest-wins, so a config placed under `crates/` outranks the repository root for the normal
inner loop. `only_the_repository_root_carries_a_cargo_config` refuses that shape outright: a setting
that applies depending on which directory you happen to be standing in cannot be reviewed by reading
a diff.
Two content rules follow. `every_cargo_config_table_is_windows_scoped` requires every table to be
scoped to `x86_64-pc-windows-msvc`, as an allowlist rather than a banned-table list, because a banned
list is a hand copy of cargo's vocabulary and would rot the day cargo adds a table. And
`no_committed_cargo_setting_decides_which_program_runs` refuses `rustc-wrapper`, `rustc`, `rustdoc`
and `runner` in every table, the Windows one included. `linker` is deliberately *not* in that set:
a linker receives object files and rustc's own link arguments, while `rustc-wrapper` and `rustc`
receive every source file in the tree, `rustdoc` runs the doctests, and `runner` receives the
compiled test binary — which would turn `cargo test`, the command every gate runs under, into
"execute a program of this file's choosing" before any assertion in any of them is reached.
The guarantee, exactly: a committed cargo setting in this repository may name the linker the Windows
dev box runs, and no other program, on no other host.
## Why `just xtask`, and not `cargo xtask` [#why-just-xtask-and-not-cargo-xtask]
The usual convention for a repository's build tool is a `cargo xtask` alias. **There is deliberately
no such alias here.** An alias lives in `.cargo/config.toml`; `build_config_gate.rs` requires every
leaf path in that file to sit under `target.x86_64-pc-windows-msvc`; and cargo does not allow
`[alias]` to be target-scoped. No spelling of the convention passes the gate that protects the Linux
boxes holding the credential store, so `xtask/src/main.rs` says so in its own header and a `just`
recipe is the entry point instead: `just xtask `, which is `cargo run -p xtask -- `.
That tool has four commands and one consumer each — and no fifth, because a command nobody
consumes is a surface that rots unobserved, which is the defect the crate was written to remove:
| command | consumer | output |
| --------------- | ----------------------------------------- | ------------------------------- |
| `crates` | the local recipes' `ci_crates :=` | one line, `-p a -p b …` |
| `affected` | both workflows' `plan` step | seven `key=value` lines |
| `feature-lanes` | a human, and the gates | `keytrigger,trigger` |
| `docs-bins` | the release's `bins.json` docs-data asset | JSON: every workspace `[[bin]]` |
[CI derivation](/docs/trader/developer/ci-derivation) explains how `affected` selects and how the local
recipes are held to the same spelling; [Testing](/docs/trader/developer/testing) covers the tiers that run
over what you built.
# CI derivation (/docs/trader/developer/ci-derivation)
"What does CI run for this change" has exactly one implementation: `xtask/src/ci/`. Everything that
needs the answer calls it — both workflows' `plan` step (`affected`), the local recipes' crate roster
(`crates`), and a developer at a prompt. `xtask/src/lib.rs`'s header states the rule those three
consumers exist to serve: there is no second copy to keep in step, so a new exclusion, feature lane
or trigger crate is one edit.
## The output contract [#the-output-contract]
`xtask/src/ci/mod.rs`'s `Plan::render` emits seven `key=value` lines in a fixed order — `any`,
`crates`, `suites`, `hist`, `core`, `app`, `lightgbm` — which the workflow turns into job outputs.
That shape is a contract with the workflow files, not an internal detail, and `suites` is a JSON
array whose order becomes matrix-leg order.
## Selection [#selection]
The rule is the same for a pull request and for a push to `main` (a push just diffs against the
pre-push SHA instead of the merge base): the crates a changed file **owns**, plus every crate that
transitively depends on them through a normal dependency, intersected with the CI roster — except
that a `.rs` file under the owning crate's own `tests/`, `benches/` or `examples/` seeds no
reverse-dep walk, since nothing outside that crate can link a test-target source. A crate whose
changed files are all of that shape is therefore selected alone; a `src/` change, or a non-`.rs`
file under `tests/` (a fixture a `src/` file may `include_str!`), restores the full walk for that
crate. Dev-dependents are added exactly **one hop**.
That one-hop rule is the whole design of `xtask/src/ci/graph.rs`. A dev-dependency is a test edge,
not a layering edge, and this workspace dev-depends upward on purpose; feeding those edges back into
one graph makes it cyclic, and a transitive walk then escapes the layering entirely. Measured: the
chain `oanda -> bridge-core =dev=> binance -> core =dev=> script -> indicators` is how one
indicator-registry edit reached an unrelated venue crate, the DataFusion lane and every Polymarket
suite — 31 crates where 9 were affected.
Narrowing must never silently under-test, so three things escalate to the full roster: a
workspace-global path (`GLOBAL_PREFIXES`, except a purely additive `Cargo.lock`-only change), an
unresolvable diff base, and `CI_FULL=1`.
### The diff base [#the-diff-base]
`xtask/src/ci/git.rs` resolves whose changes a run is testing, and every rung of its ladder was
forced by a measurement. `actions/checkout` on a `pull_request` event checks out the **merge ref**,
not the head; that commit's first parent is the base-branch tip and its second is the PR head. The
payload's `base.sha` field lags that parent, and a three-dot diff cannot repair the gap because
`base.sha` is already an ancestor through the first parent. Measured on one pull request that
changed 2 files: `base.sha` was two commits behind, the diff arrived with 40 paths including a
global one, and the plan escalated to the full matrix on the strength of files belonging to two
other pull requests. `HEAD^1..HEAD` on the same merge ref returns exactly its own 2 paths.
The failure mode of getting the base wrong is *under*-testing. An unclassifiable parent pair, a base
branch that is not an ancestor, an orphaned `before` SHA, unrelated histories, a failed diff — each
falls through to the next, more conservative rung, and the last rung is `None`, which the caller
reads as the full matrix.
## The five gates [#the-five-gates]
**`ci_plan_gate.rs`** holds the derivation itself: the diff-base ladder over real git repositories
with actual merge commits — the only test in the tree that builds them — plus the pure selection
rules over synthetic graphs. It cannot be skipped for a change that could break the derivation,
because `GLOBAL_PREFIXES` names `xtask/`, so any planner change escalates to the full roster. That
escalation is not a convenience: the planner computes the plan for its own pull request, so without
it a bug that selected too little would select too little for itself, go green, and conceal itself.
**`local_gate_mirrors_ci.rs`** holds the local recipes to what CI derives. The measured cost of not
having it: the local crate roster one crate behind the derivation, the release workflow's third copy
six behind, and one feature lane naming four features where the suite named six. Its first direction
no longer compares a copy to a truth, because there is no copy — `ci_crates :=` *calls* the
derivation, and what is asserted is the property that made the comparison unnecessary: the assignment
invokes it and names no crate. The second compares the `clippy` and `features` recipes against the
suite script on subcommand, packages, features, target and flags. `UNMIRRORED_SUITE_STEPS` enumerates
the suite's structural steps that cannot be mirrored at all — the recipes run through PowerShell on
the Windows dev box, where `grep` does not exist — so that hole is measured rather than invisible.
Its third, `every_suite_key_is_both_implemented_and_plannable`, holds the suite keys in the script
and in `FEATURE_SUITES` to one set — the direction a comparison of commands is blind to.
**`feature_suite_roster_gate.rs`** is where that key pairing is argued, and the two disagreements
are not symmetric. An arm with no key can never be planned: the commands exist, the mirror gate
dutifully requires the recipes to match them, and the lane reads in review as covered code while CI
compiles not one line of it — silent, permanently. A key with no arm is loud. The check itself was
written here too and deleted rather than shipped twice, so what this file keeps is the half nothing
else can see — the workflow's own prose, where a comment may name one suite key or all of them,
never a partial list, because that comment had already rotted to eleven of fourteen.
**`feature_lane_coverage.rs`** asks the missing direction — that every declared Cargo feature is
compiled by *some* lane. A feature no lane turns on is never type-checked, never linted and never
run, and looks exactly like covered code in a diff. Coverage is computed to a fixpoint over three
activation rules: named by a lane, reached through a crate's own feature closure, or turned on by a
dependency declaration that resolver-2 unifies across everything built together. `default` gets no
special case and an empty feature array gets no exemption — emptiness describes the array, not the
code behind the `#[cfg]`.
**`lockfile_gate_mirror.rs`** covers the one invocation in the merge gate that passes `--locked`.
The incident: a commit added a dev-dependency to a manifest and touched no `Cargo.lock`; every local
check was green because a bare `cargo check`, `test` or `clippy` silently *updates* the lockfile and
carries on. The whole missing delta was one string in one array. The gate pins that CI still runs the
shared script, that it runs in the one job with no condition on it, that the pre-push recipe runs it
*before* pushing and before any lane `cargo check` rewrites a stale lock, and that the local recipe
delegates rather than reimplementing — because a hand-rolled copy would go green and red at the same
moments while losing the diagnostic, which is the entire reason a local runner is worth having.
# Decision records (/docs/trader/developer/decision-records)
Vike Trader keeps one page per **architectural verdict** — decisions that were investigated,
concluded, and are expensive to re-litigate. The directory's own README states the failure it exists
to prevent, and it is concrete: an investigation that was already run, concluded, and written down
*somewhere unreachable* gets run a second and a third time, "because nothing in a fresh checkout says
it was ever settled."
A record is not a design document. Designs and execution plans live elsewhere; this directory holds
**answers to questions** — most of them "no", or "not this way", or "not yet" — "plus the one field
that keeps a 'no' from ossifying into dogma: **what would reopen it**."
## The shape of a record [#the-shape-of-a-record]
Files are named `NNNN-short-title.md`, numbered in the order records are *written* rather than the
order decisions were made — the date field carries that. Each is kept to roughly one screen.
| field | holds |
| -------------------------- | --------------------------------------------------------------------- |
| **Status** | the vocabulary and the rule, in one cell — see below |
| **Date** | when the decision was made, not when the record was written |
| **Context** | the question that was asked |
| **Verdict** | what was decided, in one or two sentences |
| **Why** | the reasoning, with citations |
| **What would reopen this** | "the load-bearing field. A verdict with no reopen condition is dogma" |
| **Sources** | where the above was read from |
## How to read a Status line [#how-to-read-a-status-line]
The vocabulary is `accepted`, `superseded by NNNN`, `deferred`, and `proposed`. One of the four
carries an explicit prohibition.
`proposed` means written up, awaiting the author's ratification, and — in the README's own words —
**never to be cited as settled** while it says so. Not every record in the directory is `accepted`,
so the status is the first thing to read, not the last.
The record's own `**Status:**` line is the authority; the index table carries the second copy, and
those two are the only places the word may appear. "An ungated hand copy of a word that changes on
ratification" is the rot this directory is built against, so it is not restated a third time — not in
a record's prose, not in the index's narrative, not in the root project instructions.
The two copies are compared as the **leading word only**, and that is deliberate: a record may
qualify its status in prose — naming what it supersedes, or recording that a finding inside it was
retracted — and the index may decorate its cell with a warning mark, without either counting as a
disagreement. So read the whole line. The first word is what the gate checks; the rest is where the
qualification lives, and some `accepted` records use it to mark themselves as the weakest in the
table and say exactly how far they may be cited.
## The gate, and the incident behind it [#the-gate-and-the-incident-behind-it]
`crates/vike-ops/tests/decision_index_gate.rs` machine-checks the index against the records on disk,
in both directions. It exists because of a specific failure: the index carried a standing warning
paragraph about one record, while that record had **retracted the finding in its own text**, in the
first sentence of its own `**Status:**` line. "The index was still telling a reader the opposite.
Nobody noticed, because nothing compared the index to the records it indexes."
`every_record_has_an_index_row` catches the direction that bites a new record — the common miss,
since "adding the file is the interesting work and editing the table is the afterthought."
`every_index_row_names_a_real_record` catches the direction that bites a rename.
`every_index_status_matches_the_record` is the leading-word comparison above, and
`every_record_declares_a_known_status` derives its vocabulary *from the README's own format table*
rather than copying it into a constant, "so there is one authority for it and this file cannot drift
from the page it polices." Underneath sit three floor tests: both stores are keyed on the four-digit
id and both used to overwrite on a collision, so a duplicate made one claimant **invisible** to
every rule above, and which one depended on directory order.
Three things are deliberately **not** checked. The title cell, because it is a one-line gloss rather
than a copy of the record's heading. Numbering being gapless, because parallel branches can
legitimately race for the next id and "a gap is a merge artifact to fix by hand, not a CI failure."
And the prose *outside* the table — the very paragraph that motivated the gate — because "no text
rule can tell whether a narrative note about a record is still true; only a reader can." The gate
catches the structural half and says so, rather than implying coverage that does not exist.
## Writing one [#writing-one]
Add a record when a decision is **durable, non-obvious, and would otherwise have to be
rediscovered**: a proposal was declined, an approach was measured and rejected, a built thing was
deliberately removed, or a rule was adopted that constrains future work. Do not add one for a
routine implementation choice — that belongs in the code's own module doc, which is where this
codebase documents by default.
* **Cite by symbol, never by line number.** Line numbers rot silently, and
`crates/vike-ops/tests/citation_gate.rs` bans them. The README's own bullet records that it used
to claim these Markdown files were *not* machine-checked, and that the claim was false — the gate
scans every tracked `.md` outside its exclusion list, verified by planting a bad citation into a
record and watching `no_citation_pins_a_line_number` redden.
* **Never invent a rationale.** "A record whose 'Why' was reconstructed from a plausible guess is
worse than no record, because it will be trusted." Where the reasoning could not be recovered, the
field says so and names what is missing. Several records carry that marker; they are honest gaps,
not oversights.
* **Superseding beats editing.** When a verdict is reversed, write a new record and set the old
one's status to `superseded by NNNN`. The directory's worked example is a record whose reversal
"is more instructive than either decision alone."
The practice generalises the principle the rest of the gate family runs on: prose that can rot
becomes a test. Decision `0007` is that rule's own record.
Related: [Docs gates](/docs/trader/developer/docs-gates) ·
[Architecture](/docs/trader/concepts/architecture) ·
[Drift gates](/docs/trader/developer/drift-gates)
# Docs gates (/docs/trader/developer/docs-gates)
Six gates in `crates/vike-ops/tests/` walk markdown and doc comments rather than code. Every one of
them exists because a specific written claim was measured wrong, and all of them are text-only — no
cargo invocation, no symbol resolution, no new dependency. One is the exception: it runs a process.
Because markdown belongs to no workspace member, the CI derivation cannot reach these gates by
reverse-dependency closure. `xtask/src/ci/tables.rs`'s `DOC_GATE_CRATE` force-adds the one crate that
owns them whenever a `.md` file changes. Before that existed, a documentation-only pull request
planned zero crates and not one prose gate ran.
## `citation_gate.rs` — a citation must resolve, and must not pin a line [#citation_gaters--a-citation-must-resolve-and-must-not-pin-a-line]
Four rules. A backticked path anchored at a repository top-level directory must exist on disk; in
`` `path`'s `SYMBOL` `` the file must contain `SYMBOL` as a whole token; a `path:NNN` that resolves to
exactly one in-repo file is **rejected outright**; and in `` `page.md`'s "Title" `` the cited markdown
must still carry a section with that title.
The ban on line numbers is the interesting part. 26 of the 33 in-repo `path:line` citations were
stale, and the only ones that had *not* rotted pointed into frozen vendored code — so a line number
into living code is structurally doomed, and the repair is the symbol form the tree had already
invented on its own. Rule 4 exists because a *move* rots a pointer the other three cannot see: a
documentation reorganisation left two section pointers behind, both green, one of them sending an
operator triaging a red CI gate to a runbook at an address that no longer held it.
The gate checks the unambiguous subset and declines the rest on purpose — a bare shorthand path is
relative to an unstated base and cannot be resolved without guessing, and a false positive teaches
people to write exceptions. Coverage is therefore partial, a floor test pins it so it cannot decay to
checking nothing, and every blind spot is written down. One exclusion is conditional: a frozen design
note is not scanned **unless** it declares a `**Status:**` line, because that line answers "where does
this stand" and a reader takes it as being about now — one such spec declared a gap that had already
been closed, and an orchestrator briefed an agent to implement code that already existed.
## `docs_constants_gate.rs` — a documented default must equal its constant [#docs_constants_gaters--a-documented-default-must-equal-its-constant]
One operator page said a `SIGTERM` loses up to 5,000 rows per series. Both numbers on it were right
and the sentence was wrong: the recorder has two in-memory stages and the page named one. The real
bound for a single-series recorder is 70,536 rows — a 14× understatement that survived months.
Each row proves four independent things: that one line of the markdown carries both the row's context
anchor and its number verbatim as the page spells it; that the matcher finds the symbol in the cited
Rust file and the literal beside it equals the claimed value; that the two are the **same quantity**
(`5,000` == `5_000`, `30 s` == `30`, `10µs` == `10_000` nanoseconds); and that a row converting between
units names both of them. Without the third rule a row could pin a page number and a constant that have
nothing to do with each other and stay green forever; without the fourth, a row free to assert a 1000×
difference could hide a real disagreement behind a made-up factor.
## `one_authority_gate.rs` — one fact, one authority [#one_authority_gaters--one-fact-one-authority]
That same 5,000 was stated in two documents, and the failure mode is what makes duplication expensive
rather than untidy: fixing one file leaves the number alive in the other, where it reads as
independent confirmation and grows back.
The rule is deliberately narrow. **A bare number is never flagged** — years, record ids, ports and
percentages all repeat legitimately, and a gate on bare numbers would report hundreds of coincidences
and earn an exception table until it meant nothing. What is flagged is a number in the same semantic
company twice: the pair `(identifier, number)`, either by proximity to the nearest qualifying
backticked identifier on the line or as an explicit `key = number`. Measured filters kill the
coincidence classes — single digits are excluded, date-shaped numbers are incident prose, line pins
belong to the citation gate, and digits inside an identifier's own name are part of the name.
## `unrun_command_gate.rs` — a documented command's output, checked by running it [#unrun_command_gaters--a-documented-commands-output-checked-by-running-it]
The other five compare prose to a file; this one compares prose to a **process**. Three consecutive
review rounds on one pull request found nothing but this defect — including an emergency-halt recipe,
on the page whose first heading is "STOP EVERYTHING, RIGHT NOW", whose `sed` anchor never matched, so
the variable came back empty and the next line failed.
Harvesting reads markdown to decide *which claims exist*; it never feeds a harvested string to a
process. Every argument vector this gate spawns comes from a `const` array in its own source, so the
set of executable commands is fixed at compile time and changing it is a reviewed diff — not
something a markdown edit can reach. Do not "finish the job" by running the harvested pairs: that
would hand anyone who can edit a `.md` file the ability to run a command in CI. An unrunnable claim is
**declared** instead, and a test asserts that every harvested claim is either checked or declared, so
the unverifiable population cannot grow unnoticed.
The harvester narrows by adjacency: 3,325 program-shaped candidates, 631 with an argument shape, 78
with a claim phrase somewhere in the block, and 14 with that phrase *adjacent* to the command. A
module doc is one comment hundreds of lines long, so a block-wide search paired unrelated sentences —
64 of the 78 were that.
## The two index gates [#the-two-index-gates]
`decision_index_gate.rs` holds the decision-records index to the records it indexes. The incident: the
index carried a standing warning that one record had already **retracted in its own text**, and
nothing compared the two. An index that omits a record is worse than a missing record — the reader
who checks the table concludes the question was never settled and re-litigates it. It gates four
directions plus a floor of three tests, because the gate had three ways to be silently disarmed, and
the status vocabulary is *derived* from the index's own format table rather than copied into a
constant.
`superpowers_index_gate.rs` is the same shape over the design-document index, which opened by
claiming to be a 1:1 list and carried six hand-written numbers that nothing checked. Both gates leave
the semantic half alone: no text rule can tell whether a narrative note about a record is still true,
and stating that limit beats implying coverage that does not exist.
How to read a settled verdict is on [Decision records](/docs/trader/developer/decision-records).
# Drift gates (/docs/trader/developer/drift-gates)
A few build outputs in Vike Trader are **committed** rather than produced at build time, each to
keep a whole toolchain — or a whole upstream — out of a clean checkout. That trade buys a fast,
dependency-light build and costs one thing: a committed artifact can drift from the source it came
from, silently, and no compiler will notice.
The family rule is three clauses, and every member obeys all three: **re-derive from source in CI,
compare exactly, and pin the generator beside the artifact** — not in a central document, which is
the copy that rots.
## The two members [#the-two-members]
**The cTrader protobuf bindings.** `crates/bridges/ctrader/src/generated/openapi.rs` is
pre-generated and committed, so a normal build never runs prost-build or protoc — they stay out of
the build graph *and* the supply chain. A path-filtered workflow regenerates from the vendored
schema with the pinned toolchain and fails unless the committed file is byte-identical, a plain
`cmp` whose reason `crates/bridges/ctrader/src/proto.rs`'s `generated` module states beside the
`include!`: the committed file is prost-build's verbatim output for the vendored schema, renamed on
commit.
**The vendored IBKR wire-protocol client.** `crates/bridges/vike-ibkr/vendor/ibapi` is a
workspace-excluded copy of an upstream crate, re-derived by a path-filtered workflow that runs a
standalone drift script. The check lives in a script rather than inline in the workflow because it
is not one command: it reconstructs, in stages, a vendoring procedure a person performs by hand.
`crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md`'s *Refresh procedure* is that procedure —
re-clone upstream at the desired tag, re-copy `src/`, `Cargo.toml` and `LICENSE`, re-apply the
three manifest trims and the three test-module drops, update the record's own fields — and it ends
by telling the refresher to "run the gate before pushing", naming the same script the workflow
runs. A check the next refresher must run locally cannot live inside a workflow file.
Both are separate, path-filtered workflows rather than jobs in the main CI run, and both carry
SHA-pinned actions, a read-only token and a job timeout — the runner also runs live trading.
## Why the comparison styles differ [#why-the-comparison-styles-differ]
The bindings are compared byte-for-byte. The vendored client is compared three different ways in one
run, and its `src/` under rustfmt normalization rather than as raw bytes. That is not a weaker gate
written by a tired author — it is the only comparison that could have been green the day it was
written.
The drift script compares the three things that were copied — `src/`, `Cargo.toml` and `LICENSE`,
the set `crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md`'s "What was copied" names — and each on
its own terms, argued beside the code that performs it.
**`LICENSE`** gets a raw `cmp`: nothing processes it on either side.
**`Cargo.toml`** gets a raw `cmp` after applying the record's documented trims and two declared
normalizations — measured 2026-08-22 against the pinned upstream commit, the trims are
byte-reproducible, "the whole difference being the vike provenance banner at the top of the
committed file and one trailing blank line."
**`src/`** is compared as rustfmt-normalized text, because the raw bytes are not reproducible and
never were. Upstream ships a `rustfmt.toml` with `max_width = 150`; that file was not among the
copied ones, and the copy was reformatted under this repository's own config —
`crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md` records both, in its "What was copied" section.
Measured 2026-08-22 at the pinned commit, **276 of the 280 committed `.rs` files differ from
upstream, every difference line-wrapping and none of it code** — and running this repository's
rustfmt over upstream's `src/` collapses that to zero. A byte gate there would have been red on the
day it was written.
The normalization is honest about both sides of the trade. Both trees go through the same rustfmt,
which re-prints parsed source while preserving comments verbatim — so a changed identifier, a
changed literal, an added or removed statement, an edited comment, a reordered item and an added or
removed file all survive it and redden the gate. What it cannot see is whitespace and wrapping
inside the committed files: "rustfmt erases nothing but layout, so the residual is layout alone."
## Where each pin lives [#where-each-pin-lives]
The third clause keeps the other two meaningful. The bindings' regen recipe is a committed,
standalone project that `=`-pins its codegen crates and commits its own lockfile — freezing the
pretty-printer whose output must match byte-for-byte — and CI regenerates with `--locked`. Those
pins may be bumped only together with a re-commit of the generated file.
For a vendored copy the only build input is an upstream commit, so the pin lives in the vendored
tree's own `VENDORED.md` and the gate **parses it there** rather than carrying a second copy a
refresh would have to remember to update. It insists on a full 40-hex commit, because "a tag or a
branch can be moved under us, and an abbreviated sha can collide", fetches that commit rather than a
ref, and cross-checks the recorded version against the committed manifest's — "a half-finished
refresh is exactly the state where they disagree."
The vendoring is not a pure copy. One set of local edits — three test-only modules dropped from
`src/` — was *undocumented* until the gate measured it, and they are reconstructed rather than
ignored, because "a gate that merely IGNORED files missing from the copy would let the next refresh
drop a load-bearing source file in silence."
## The member that left, and the rule it left behind [#the-member-that-left-and-the-rule-it-left-behind]
The JForex sidecar jar was the third member: it was committed, and a dedicated workflow rebuilt it
from the committed Java sources to prove the two still agreed. It is a *runtime* artifact rather
than a compile input, so the release workflow now builds it from those sources on every tag and
attaches it to the Release, checksummed in that release's `SHA256SUMS`, and a release-tools fetch
script installs it into the runtime-tool directory `crates/vike-model/src/state_path.rs`'s
`PROJECT_BIN_DIR` names — "so there is no second copy to drift from and nothing a `git diff` could
fail to notice."
Deleting the workflow with the question would have been wrong, because two properties it rested on
are still true and now more load-bearing: "the release ships bytes nobody reviews, and a
`SHA256SUMS` line is only worth anything if somebody can re-derive the bytes it describes." So it
became a **reproducibility** gate — it builds and tests the jar, runs a protocol smoke, then builds
it twice from a clean directory and compares the two byte for byte. The build script had claimed
"same sources plus same JDK gives a bit-identical jar" all along, a claim nothing ever checked,
because the old gate compared a build against a human-committed artifact and "could not tell
reproducibility from luck."
**The rule this leaves: an artifact a deployed binary must find is a release asset, never a
committed one — commit the pin, ship the build.**
The pin outlived the membership. `crates/bridges/dukascopy/tests/jdk_pin_gate.rs` holds the JDK
version equal across four files — the gate, the release that builds the shipped jar, and both
provisioning scripts — because `javac` output differs across JDK releases. Its header names the
failure with no other detector: if the gate's pin and the release's pin drift, "the gate reproduces
a jar nobody has, the release publishes a jar nothing checked, and both workflows stay green."
Related: [Supply chain](/docs/trader/developer/supply-chain) ·
[Release gates](/docs/trader/developer/release-gates) ·
[Docs gates](/docs/trader/developer/docs-gates)
# Developer guide (/docs/trader/developer)
This tree is for somebody changing Vike Trader rather than running it. It is organised around one
idea that shapes almost every rule in the repository: **a claim that can rot is either derived or
gated, never restated in prose.** That is decision record 0007, whose own `Status:` line is the
authority for whether it still stands — this page does not carry a third copy of that word.
## Why the doctrine exists [#why-the-doctrine-exists]
It was not a preference. Three independent measurements from the tree that motivated the gates:
* **Citations.** 26 of the 33 in-repo `path:line` citations were stale, and seven fully-qualified
paths pointed at files that no longer existed. Git archaeology showed every one was exactly right
on the day it was written. `crates/vike-model/src/venue_margin_support.rs` alone carried four that
landed on a blank line, a `};`, and a doc-comment fragment.
* **Hand copies of a derived list.** The local recipes' CI crate roster had drifted one crate behind
the derivation, the repository's own guidance file four behind, and the release workflow's third
copy six behind — while one feature lane named four features where the suite named six. Every one
of them green, because nothing checked. `xtask/src/lib.rs`'s header records what replaced them,
and what still lives elsewhere.
* **Counts in prose.** The library-layer settings work-list was written down as 63, then 59, and
measured 77. It grew across five pull requests that each had no reason to notice, while every
prose statement of it went stale.
The decisive datum is the citation one. The only `path:line` citations that had *not* rotted pointed
into frozen vendored third-party code. A line number into living code is structurally doomed — so
`crates/vike-ops/tests/citation_gate.rs` does not police line numbers, it **bans** them, and the
repair is the rot-proof form the tree had already invented on its own before any gate existed:
```text
`crates/bridges/bybit/src/exec.rs`'s `DEFAULT_LEVERAGE`
```
A symbol citation survives every edit a line number does not, and it breaks only when the cited
thing is genuinely renamed or deleted — which is exactly when the documentation is wrong.
## What a gate looks like here [#what-a-gate-looks-like-here]
Almost all of them are **text-only**: they walk the real tree, strip comments and string literals,
and compare two things that must not disagree. No cargo invocation, no symbol resolution, no new
dependency. `crates/vike-ops/tests/settings_registry.rs` is the shape the rest are modelled on, and
`crates/vike-ops/tests/layer_gate.rs` and `crates/vike-ops/tests/local_gate_mirrors_ci.rs` say so in
their own headers. They fail a pull request; they are not review comments.
Five properties recur, and they are worth recognising when you read a failure:
**Gate the class, not the instance.** A defect that was an advertised-but-unreachable settings
*layer* did not get a test for that layer — it got `crates/vike-config/tests/layers_are_reachable.rs`,
which drives every layer named in `PRECEDENCE` and panics on one with no scenario rather than
skipping it.
**A ratchet where a flat ban is unavailable.** `settings_registry.rs`'s `LIBRARY_PIN` pins the set
of offending rows as a fixed-size array, so its declared length *is* the count and no prose is
allowed to be the authority. It may shrink, never grow.
**Measure the blind spot; do not assume it.** `MAP_LOOKUP_PROVEN` in the same file fails equally on
an addition, because a blind spot that is only ever an upper bound is not measured, just assumed
smaller.
**A gate that walks nothing passes everything.** Every walking gate carries a file-count floor. That
earned its keep immediately: `layers_are_reachable.rs`'s first version rooted one directory too
high, the self-crate filter emptied the walk, and it failed on the floor rather than passing
vacuously.
**False positives are worse than no gate**, because they teach people to write exceptions. So
`citation_gate.rs` checks only the unambiguous subset and declines bare shorthand paths — roughly
297 such tokens — on purpose, and every exception table carries a per-row written reason.
And one rule about writing a gate: mutation-prove it, then verify the mutation actually applied. One
attempt appended its test mutation to the end of a file and landed in the `#[cfg(test)]` half the
gate correctly discards — it silently did nothing, and the gate stayed green.
## The families [#the-families]
The architectural rule the structure gates enforce is explained on
[Architecture](/docs/trader/concepts/architecture); how to read a settled verdict is on
[Decision records](/docs/trader/developer/decision-records).
# Release gates (/docs/trader/developer/release-gates)
A release leaves the repository as bytes nobody re-reads: an image, a set of unit files, a handful
of binaries, a published source tree. Five gates in `crates/vike-ops/tests/` hold the properties of
those artifacts that no compiler can see — each chosen because its violation stays invisible until
the artifact is *run* in production.
## The container image [#the-container-image]
`crates/vike-ops/tests/container_image_gate.rs` opens with the fact that shapes it: **no runner has
a container runtime.** Measured 2026-08-24 on the box the release workflow is pinned to, none of the
four common runtimes is installed and the docker socket does not exist, while a control probe finds
the ordinary shell tools — "so that is a fact about the runner and not about a broken check."
Coverage is split by what is provable without one. The entrypoint is executable shell, so it is
*run*: `the_entrypoints_own_selftest_passes` drives the real script through the stamp, the
verification, the wholesale swap and every refusal. The Dockerfile is text, so the rest are text
rules. `the_entrypoint_execs_the_daemon` is called "the single most dangerous line". Without `exec` the
shell stays PID 1, and a shell does not forward SIGTERM to its children: `docker stop` waits out its
timeout then SIGKILLs, so `cancel_orders_on_shutdown` never runs and "the resting book is ABANDONED
at the venue" — the defect `crates/vike-ops/tests/graceful_stop_pin.rs` exists to keep fixed,
reached through the container instead of the unit file. Alongside it: the base image is pinned by
digest, no packages are installed, both released binaries are present, the stop timeout outlasts the
daemon's own shutdown deadline, and `no_baked_environment_variable_is_a_removed_setting` —
`vike_config::REMOVED_ENV` names variables whose presence is a hard startup refusal, and one baked
as an `ENV` line would make every container of that image refuse to start.
That the image builds, that the base's glibc satisfies the binaries, or that the entrypoint works
under a real PID 1. `crates/vike-ops/tests/container_image_gate.rs` says so itself, in its *What it
deliberately does NOT prove* section: none of it is reachable without a container runtime, so the
release script that stages and builds the image carries the runtime check instead — "the only place
the glibc-too-old failure can be observed at all."
That check runs `--version` on *every* version-capable link — six of them, all symlinks into one
dispatcher — inside the freshly built image before anything is published.
`crates/vike/src/lib.rs`'s `Route::Version` records the same rule from the dispatcher's side: "the
image smoke runs `--version` through every link, the dispatcher's included." One link is not
enough — a released image once answered `vike-tradehub --version` with that tool's usage error, a
break a single-link smoke catches only for the link it runs.
## The shipped units [#the-shipped-units]
`crates/vike-ops/tests/deploy_layout_gate.rs` holds one rule — every shipped daemon unit describes
one project folder, and the same one throughout — and names two defects that shipped before it
existed. The first was a layout that no longer existed anywhere, so a fresh deploy rebuilt an
abandoned three-location shape. The second was worse: `EnvironmentFile=` pointed at the **credential
store**. Nothing was exposed, but the route shipped, and it inverts a guarantee four gates rest on —
the live, control, reconcile and Telegram flags are read off the real process environment precisely
*because* the store never reaches there.
The write grant is the instructive rule, because its first version caused what it looked like it
prevented. It demanded exactly one `ReadWritePaths=` naming exactly one path, on the argument that a
second path is a second location. It is not: every path was already required to be under the unit's
own root, so a second path inside one folder is a *narrower* grant. The rule forced the one daemon
that legitimately writes in two places to grant the only single path covering both — the root —
which on a shared folder covered a live daemon's binaries, its notional ceiling, its credential
store and its armed kill switch. Containment is checked instead: no granted path may reach
`ExecStart=`'s binary or any `ExecStartPre=` binary — not the executable and not a directory holding
it, since write permission there is enough to unlink and replace the file — nor the settings
directory the unit names. The gate states its own limit: intent is all a text gate can read.
## The fxcm-linked artifact [#the-fxcm-linked-artifact]
`crates/vike-ops/tests/release_fxcm_artifact_gate.rs` governs a second daemon binary built from the
same tagged commit with `--features fxcm`. Its rule is one line: **a release may grow an
fxcm-linked artifact; it may not change the one everybody deploys.** A feature build writes to the
same output path the default build just produced, so getting it wrong silently replaces the binary
the deploy installs — with one that dies at exec, before `main`. The extra asset rides in the
checksum manifest but is never installed, checked over a *derived* subject rather than a hand list,
because "an absence check never opens the file nobody remembered to add, and reports a pass for it."
Third, an asset named `-fxcm` must actually be linked: with no SDK the identical command produces a
stub that builds green, so the script reads the ELF's dynamic section rather than trusting an exit
code, and the gate runs it against a fake `cargo` — the only way to prove the guard is *wired into*
the build.
This gate's behavioural half once opened with an early `return` on a missing toolchain, so a runner
without a C compiler reported it green over a release script none of it had executed. Since
2026-08-28 the cases differ: off an ELF platform they are `#[ignore]`d with the reason attached, and
a missing tool *on* Linux fails, because a box that can build the workspace already has all three.
## The public mirror and the dispatcher [#the-public-mirror-and-the-dispatcher]
`crates/vike-ops/tests/publish_mirror_gate.rs` guards the script deciding what leaves the private
repository, on two properties. **ALLOW is an allowlist** — a catch-all turns the script into
"publish everything except…", which fails in the wrong direction, silently, and once. And **the
forbidden-token scan refuses** rather than warns: "redaction alone is a habit; redaction plus a
refusal is a gate." Neither is checkable by reading the script, so the gate *runs* it over the real
tree and inspects the result.
`crates/vike-ops/tests/multicall_gate.rs` requires the multicall dispatcher to do nothing before
handing over, and one tool to run per process. It performs the process's single environment sweep,
then hands control to a tool that boots for itself — if the dispatcher also booted, one process
would walk for settings twice, and the second walk is blind to the settings-directory override. That
is the defect that once put a live daemon on no policy, no config and no credentials, every venue
silently on paper, no error anywhere. The boot-ownership gate cannot catch it: it keys on crate
directories containing a boot call, so "the dispatcher boots and the tool it calls boots" reads as
two crates each booting once. The one-tool rule follows from process-global state — a second tool
would inherit the first's logging and handlers in a process that may be signing orders — so the
dispatcher panics, and the gate proves the panic is real.
Related: [Supply chain](/docs/trader/developer/supply-chain) ·
[Drift gates](/docs/trader/developer/drift-gates) ·
[Safety gates](/docs/trader/developer/safety-gates)
# Safety gates (/docs/trader/developer/safety-gates)
These gates cover the code you reach for when something is wrong. Each one is a text gate over the
source rather than a runtime test, and each one says out loud what a text gate cannot prove — because
in this family, false confidence is the failure.
## `kill_switch_gate.rs` — the page is read at 3am [#kill_switch_gaters--the-page-is-read-at-3am]
The kill-switch runbook is read by someone who is not going to verify it, which makes it exactly the
kind of prose this tree has learned not to trust. A kill-switch page that has rotted is worse than
none, because the operator stops looking for the real switch.
Seven rules. Each documented mechanism's file must exist and contain its symbol as a whole token; the
page must still carry that row's operator trigger string and its citation; **every** citation anywhere
on the page must resolve, wider than the row table, so a claim added in prose is gated the same as a
table row; no line numbers; a floor on the row and citation counts, so the gate cannot decay into
checking nothing; a mutation self-test, without which the first three rules could be vacuously green
forever; and for the subset whose trigger is a line the binary *prints*, that the binary still prints it.
This gate cannot tell that a switch's configuration type exists but is constructed by no binary. That
finding is written on the page under "Switches that do not work" and is checked by nothing here.
Proving "no shipping binary arms this" needs a call graph, and a text gate pretending to do it would be
the exact false confidence the file exists to prevent. The same holds for behaviour: one behaviour test
was later spelled to assert the **opposite** of what it once did, and every row and citation here
stayed green through the reversal, because a text gate cannot read a test name for its meaning.
## `graceful_stop_pin.rs` — one stop flag, many triggers [#graceful_stop_pinrs--one-stop-flag-many-triggers]
Neither headless daemon installed a signal handler, so a service stop ran none of the teardown each of
them already owned. The fix was one package: the handler sets the **existing** stop flag, and the
ordinary teardown runs below a single wait on it. A second teardown route is how the two drift, and
drift here reads as "the interactive stop closes the book and the service stop does not".
So what is pinned is the shape rather than the intention. Both daemons reach the same shared stop
primitive, both install handlers, and neither declares or names a signal crate of its own — a ban
*derived* from the module that owns the surface, so it tracks the platform split rather than pinning one
literal. The trading daemon's teardown must wait on the flag, not sit below an uninterruptible park. The
handler must be installed **before** anything can place an order — the first cut of the fix failed that,
with the install below the core mount, so the maker could already be resting orders while the signal
still killed the process outright. The loser of a teardown claim must wait, never return, because a
return from `main` truncates the winner's teardown. Every shipped unit must bound the stop explicitly
rather than inherit the 90-second default, and none may carry an `ExecStop=`: one added without a
matching daemon-side poll was measured at 0.02 s with zero daemon code run.
## `feed_stop_windows_gate.rs` — exhaustive over the source, not over memory [#feed_stop_windows_gaters--exhaustive-over-the-source-not-over-memory]
The recorder's feed-stop budget is derived from the largest blocking window a feed thread can occupy
when the stop flag goes up, and its doc comment tabulated those windows. The table shipped saying
"every one of them" and "each is followed by a stop check". Both were false the day they were written: a
REST book seed ran on the shared 30-second pager agent — three times the newly-bounded dial — with no
stop poll in front of it. The same change that bounded the dial left a larger window immediately behind
it and then asserted exhaustiveness over the result.
The rule keys on **shape**, not on a list of blessed names: in a file that mounts a feed driver, an
unbounded WebSocket connect fails; every agent-shaped call is classified and must be a row of the
gate's own table of agent rungs; and a bounded call's ceiling **argument** must itself be a row of
the derivation table — the direction that catches "somebody bounded a new window and did not tell
the budget about it". What the gate cannot see is executable rather than promised: a test runs the
rules against each missed shape and asserts the miss, beside positive controls asserting the catch.
An earlier round wrote that residual as prose, and it was already wrong in the same direction as the
table it replaced.
## `held_divergence_is_visible_gate.rs` — a held divergence must reach the log [#held_divergence_is_visible_gaters--a-held-divergence-must-reach-the-log]
A held reconciliation divergence was written to an in-memory ring reachable only through a control
channel that is off by default. Measured on the live box: a folded-pass line reporting two alerts every
60 seconds for hours, zero lines at `warn` or above, and no way for anyone reading logs to learn which
two divergences were being held. The count reached the operator; the content did not.
Emitting the line unconditionally fixed the silence and bought a flood — under a quarantine policy a
divergence nothing heals is re-diffed and re-held every interval, and one day logged 396 identical
warnings with a genuinely new divergence indistinguishable among them. So the emission moved behind an
announcer that logs the **transitions** of the held set plus a slow backlog summary, and both properties
are gated: the raise path still logs, and no warning on that path is unguarded.
## `live_lock_claim_order_gate.rs` — claim before you build [#live_lock_claim_order_gaters--claim-before-you-build]
The live lock is the one-live-process-per-venue-account tripwire, and neither composition root was
holding one over what is actually live. The daemon locked the **wrong set** — one sentinel per
run-profile mount, while the builder armed an exec client for every wired venue the credential store
answered for. Measured on one startup, two log lines apart: nine live authenticated sessions, one lock.
The desktop app locked the right set at the wrong **time** — its claims came from a value that does not
exist until every exec client has been constructed, and three venue clients post a leverage call at
startup, so a lock refused there had already changed account state at three venues.
A mutation campaign then showed what pinning a *name* is worth here: three one-line edits, each
relocating the sentinel directory, left every test green. The gate pins each root's declared **chain**
instead — from the boot's state directory to the claim — with each hop checked by what it is built
from, and it refuses shapes it cannot classify rather than passing them.
## `paper_mount_arming_gate.rs` — every construction site is classified [#paper_mount_arming_gaters--every-construction-site-is-classified]
A mount arms the operator halt sentinel; a simulation must not. The accessor's own doc said two named
seams each assert on this, "so adding a third mount seam that forgets fails a test in its own crate" —
and a third seam already existed, unarmed, when the sentence was written. An assertion inside seam A
and an assertion inside seam B are evidence about A and about B; nothing was looking at the **set**.
So every file under a crate's `src/` that calls a paper-client constructor needs a row, and the
constructor set is *derived* from the type's own `impl` block and matched under whatever name the
calling file's imports give it. Mount rows must arm; simulation rows must not — and that opposite half
is not symmetry for its own sake, because the backtest-equals-paper equivalence gate drives the same
client, so a sentinel read on a simulation path would make backtest fills depend on a file lying around
in the project state directory. Test code is out of scope: it constructs unarmed books by the dozen, and
that *is* the simulation shape. The gate is file-level rather than call-site-level, with per-seam tests
bounding the residual — those answer "is this book armed", and this gate answers the one none of them
can: "is there a seam nobody is asking about".
What each of these mechanisms does is on [Halt](/docs/trader/concepts/halt),
[The dead-man's switches](/docs/trader/concepts/dead-mans-switch) and
[Reconciliation](/docs/trader/concepts/reconciliation).
# Settings gates (/docs/trader/developer/settings-gates)
Two separate questions get gated here, and they fail for opposite reasons. The registry asks
**"is every environment read declared, and does a library do it?"** The consumption gates ask
**"does anything actually read the key this file accepts?"** A settings key that nothing reads is
worse than an unimplemented feature, because the CLI reports it as the *origin* of an effective
value and hands the operator positive confirmation of something false.
## The registry [#the-registry]
`vike_ops::settings::SETTINGS` is the verbatim registry of every environment variable this workspace
reads. It is **data, not behaviour** — nothing in the runtime consults it. It exists so
`crates/vike-ops/tests/settings_registry.rs` can walk the real source tree and assert that the table
and the tree agree in both directions: an undeclared `env::var` fails CI, and a stale row fails CI
too. Rows are keyed on the pair `(name, krate)`, not on name alone, so one variable read from several
crates gets one row per crate, each carrying its own default and evidence.
Genuinely dynamic call sites — a name the program assembles at runtime — must be named in
`DYNAMIC_ALLOWLIST` with a written reason. A blind spot has to be **declared** to exist, and that
principle recurs: `MAP_LOOKUP_PROVEN` pins exactly which map-lookup rows rest on a resolved call site
rather than on the loose literal sweep, and it fails on a removal *and* on an addition, so the
remaining blind spot is measured rather than assumed smaller.
### The rule the registry exists to enforce [#the-rule-the-registry-exists-to-enforce]
**Libraries take configuration as parameters; only binaries read the process environment or the
credential store.** A `Layer` records which shape each row has — `Library` is the work-list (a
library reading global state its caller can neither see nor override), `Injected` is the target
state (a pure parser over a caller-supplied map), and `Binary` is the third correct shape.
`LIBRARY_PIN` pins the set of offending `(krate, name)` pairs as a fixed-size array, so its declared
length is the only machine-true count and no prose is allowed to be the authority for it. A ratchet
was needed because a flat ban is unavailable — dozens of legitimate rows exist and four families are
deliberately exempt — and because the count grew silently across five pull requests that each had no
reason to notice, while every prose statement of it went stale. `library_rows_do_not_grow` names the
offending pair and the two legitimate responses; `library_pin_has_no_stale_rows` fails on a removal
with "delete the line", a one-line cleanup rather than a blocker.
### The credential store is a second ratchet, on a class the first cannot see [#the-credential-store-is-a-second-ratchet-on-a-class-the-first-cannot-see]
Everything above keys on an `env::var` **call**, and the credential store is not read that way: it is
a filesystem read of a path a runtime walk produces, with no `env::var` anywhere near it. A library
loading it was therefore *structurally invisible* to `LIBRARY_PIN`, not merely unpinned.
`CREDENTIAL_STORE_PIN` closes that — same walk, same comment stripper, same `Layer` rule, keyed on
the **name** of the function that performs the read. It is a separate pin because there is no
variable name to key on and because the fix is a different instruction: "take the map as a
parameter", not "move the read to a binary".
Keying on a name left its own hole, and a real defect walked into it: a hand-rolled filesystem read
of a working-directory-relative `.env` — a second credential store outside the project settings
directory — had no name to key on and was invisible to both gates. So the ratchet takes a second kind
of evidence, keyed on the **shape**: a filesystem opener whose path argument names the store. The
residual is declared and executable — a path bound to a local first, produced by a helper, or reached
through a loop variable still escapes, and a test asserts each miss so the blind spot cannot be
quietly widened or quietly closed.
### The key that appears as no literal at all [#the-key-that-appears-as-no-literal-at-all]
Credential keys have neither a variable name nor a reader name: the loader composed each one from a
`{VENUE}_{TIER}` prefix and handed it to a map lookup, so the key existed only at runtime. One
venue's live API secret was read on every credential probe with neither a row nor a sighting, while
its demo twin had one only because a test fixture happened to spell it.
The fix was not a fourth scanner: it was making the key set **data**. `vike_model::credential_keys`
folds its suffix and tier tables over `VENUES` into the whole enumerable grid, the loader composes
its names from those same tables, and `every_generated_key_is_declared` demands a registry row for
each. Adding a venue, a tier or a suffix now reddens CI until the rows exist — exactly what the
roster contract promises for every other per-venue table.
## The two consumption gates [#the-two-consumption-gates]
`crates/vike-config/tests/policy_is_consumed.rs` exists because a risk ceiling shipped as a field,
was validated on load, was accepted by the strict unknown-key check, and was **read by nothing** —
while a module doc stated in as many words that it was consumed at the order surfaces. The field was
removed. Its rows now name a file and a needle, and `every_claimed_consumer_really_reads_it` opens
that file and looks, so claiming a ceiling is enforced when it is not turns the gate red, and so does
deleting the consumer later.
`crates/vike-config/tests/settings_are_consumed.rs` is the twin for the three types that had the
identical defect and no gate at all. A clean-install validation found five keys displayed as
effective while nothing read any of them: no control server, nothing listening, and the trace log in
the wrong directory at `trace` level — a configuration that once wrote 341 GB onto the disk hosting a
live trading node.
Both gates run four directions: every field or key has a row; every claimed consumer really reads it;
a claimed consumer must live **outside** the settings crate, since that crate parses, validates,
clamps and serializes every field and would otherwise let every row claim consumption while asserting
nothing; and a row admitting no consumer must be honest in the other direction.
`settings_are_consumed.rs` checks `pub` data rather than a fixture, because a test alone would have
left `vike-cli config show` still lying — the command reads the same rows and names the unread keys.
What each file governs is on [Settings](/docs/trader/concepts/settings) and under
[Settings reference](/docs/trader/reference/settings).
# Structure gates (/docs/trader/developer/structure-gates)
Three gates fail on the *shape* of a change rather than on its behaviour. The rule the first one
enforces is explained on [Architecture](/docs/trader/concepts/architecture); this page is about reading
the failure when one of them goes red, because in all three cases the obvious repair is the wrong
one.
## `layer_gate.rs` — dependency direction [#layer_gaters--dependency-direction]
Every workspace member declares its rank in its own manifest, and
`crates/vike-ops/tests/layer_gate.rs`'s `every_normal_vike_dependency_goes_strictly_down` asserts
that for every **normal** (non-dev) `vike-*` edge `c -> d`, `layer(d) < layer(c)`. Text-only over the
manifests, no cargo invocation.
**Reading the failure.** The edge is the defect; the number rarely is. The ranks in `TIERS` were
established by reading the real graph — all 275 normal `vike-*` edges in the tree the gate was
written against — and every one of them satisfied the rule as merged, so the tree had no layering
violation to fix when the gate landed. Raising the rank of the crate that failed will make the gate
pass and will also pull whatever that dependency drags in.
That is not abstract. One normal edge from the headless trading daemon to the GUI's shared crate
dragged the entire egui widget stack — ten crates — into the daemon, for three pure modules. The
cost was never CI minutes: it was daemon build time, binary size, and the `cargo deny --all-features`
supply-chain audit surface on the box that signs real orders. It was found by somebody noticing build
times.
Where two families are genuinely incomparable the numbers still put one above the other, and the gate
then permits the wrong-direction edge in whichever direction was chosen. Two of those choices are
deliberate, made toward the mistake that has actually happened: the whole UI stack ranks **above**
everything headless, which is what makes the motivating edge fail; and venue bridges rank **below**
the simulator, so a bridge reaching for the backtester is red while the reverse edge — the far less
dangerous one — is not.
Dev-dependencies are exempt, and that is not a loophole: a dev-dep is a test edge, and this workspace
dev-depends upward on purpose. `dev_dependencies_genuinely_point_upward` asserts the exemption is
still *needed*, so nobody can fold dev edges into the rule and have it pass for the wrong reason.
`[build-dependencies]` are not exempt, and optional versus non-optional is not distinguished — an
optional normal dep is still an edge somebody can turn on.
## `compile_time_path_gate.rs` — a running binary may not use its build tree [#compile_time_path_gaters--a-running-binary-may-not-use-its-build-tree]
`env!` over the manifest directory expands at **compile** time to the directory of the crate being
built, so the value names the tree the binary was built in, never the project it is later run from.
On the box that built it those two coincide, which is exactly why the defect is invisible everywhere
except in production.
The tree convicted itself before the gate existed: a venue bridge built its sidecar-jar and JVM
fallbacks that way, so a binary built elsewhere and deployed into a project folder found neither and
degraded to paper with nothing in the log but "bridge jar not found". Both reads were already pinned
by the settings registry — and the *deployment* consequence was gated by nothing at all. Both sites
were retired in the batch that added this gate; it exists to stop the class growing back.
**Reading the failure.** Every use in non-test `src/` of a workspace member is keyed on
`(file, enclosing fn)` and carries a `Disposition` — a closed three-variant classification, not a
free-text reason column, because these are not exemptions: several are debt somebody should still
retire, and a reason column is an invitation to write a plausible sentence and join the list. The
class that *claims* mitigation is machine-checked: `every_ladder_rung_row_is_corroborated_by_its_file`
requires such a file to actually reach the runtime store-root resolver, and requires every other
row's file not to. So the repair is either to route the path through the runtime resolver or to
classify the site honestly — never to add a row that says something plausible.
Three exclusions fall out of deriving the roster from `[workspace] members` rather than maintaining a
list: vendored third-party code, the excluded protobuf regeneration recipe (whose own compile-time
path is correct — the build tree *is* its subject), and `tests/`/`benches/`, where resolving a
committed fixture from the manifest directory is right and a gate arguing with roughly 50 such sites
would teach people to write exceptions.
## `duplicate_shape_gate.rs` — a hoisted primitive may not be re-spelled [#duplicate_shape_gaters--a-hoisted-primitive-may-not-be-re-spelled]
The motivating commit added four fresh copies of the closing-side ternary to the backtest engine
**six days after** that helper was hoisted into the shared scalar module. CI was green.
The gate walks the real `crates/` tree, masks comments and string literals, and looks for the banned
expression shapes textually. Each shape names the one helper it should call: the closing-side
ternary, and a hand-rolled quantity·price·multiplier product — which resolves to one of *three*
notional helpers that are not interchangeable, because picking the wrong one silently changes risk
arithmetic.
**Reading the failure.** `ALLOWLIST` carries an exact **count** per `(file, shape)`, and it fails in
both directions: too many sites is a new re-spelling, too few is a stale exemption. So converting an
allowlisted site forces its entry to be revisited rather than quietly outliving its reason. The gate
is textual on purpose and must not become an AST pass — an approximate matcher plus an explicit,
reasoned allowlist beats a precise matcher nobody maintains. Its limits are declared: four-or-more
factor products are margin formulas with their own helpers and are out of scope, and factors are
classified by identifier *word*, so a price bound as a bare `p` carries no price word and escapes.
# Supply chain (/docs/trader/developer/supply-chain)
Vike Trader's whole workspace compiles into one order-signing binary, and every workflow in the
product repository runs on self-hosted runners — the machines that hold the credential store and run
the live trading daemons. Three gates carry the consequences.
## What CI may fetch [#what-ci-may-fetch]
`crates/vike-ops/tests/supply_chain_gate.rs` opens with the fact that shapes it: the runners use
`clean: false` and a persistent per-runner `CARGO_HOME`, so a tool fetched by a CI step "lands on
the trading box and stays there" — and both tools it governs then execute everything.
`cargo-nextest` runs every test binary in the workspace; `sccache` is installed as `RUSTC_WRAPPER`
and wraps every `rustc` invocation on that runner thereafter. The policy predated the gate and was
already false in two places when the gate was written, which is the whole argument for gating it.
* **`every_action_reference_is_sha_pinned`** — every third-party `uses:` names a full 40-character
commit SHA. Two references had floating tags while \~25 others were SHAs. A major-version tag is
retagged on every point release, and even a patch-specific tag "is only a POINTER: a tag can be
force-moved onto any commit, so it names an intent, not an artifact."
* **`every_downloaded_archive_is_checksum_verified`** — a downloaded tool archive needs a
`sha256sum -c` in the same file. cargo-nextest was fetched from a mutable URL piped straight into
`tar`; sccache was version-pinned but unverified. A `curl | tar` "cannot be verified even in
principle: the bytes are extracted before anything can look at them." The failure message adds the
part people get wrong — take the sum from the vendor's published `.sha256` asset, because hashing
the file you just downloaded verifies nothing.
* **`no_tool_download_resolves_a_mutable_pointer`** — no `/latest/`. Kept separate because a
`/latest/` URL with a hard-coded sum is the worst combination: it breaks CI the moment upstream
publishes, "which reads as flakiness and invites deleting the sum."
Detection is on shape, not spelling. `is_tool_fetch` records that its first draft keyed on a
`.tar.gz` suffix and so would not have caught the very line the change removed — that URL has no
extension at all. It now matches bytes piped into an extractor or interpreter, a URL that is plainly
an archive, or an output flag naming one.
It proves a download is version-pinned and sum-checked. It cannot prove the sum is the *right* one,
and it says nothing about staleness: Dependabot maintains `uses:` SHA pins but cannot see a URL
inside a `run:` block, so those pins are manual maintenance forever.
## How much unsafe code exists [#how-much-unsafe-code-exists]
`crates/vike-ops/tests/unsafe_and_toolchain_gate.rs` turns "how much unsafe is in this binary" into
a number. The root manifest declares `unsafe_code = "forbid"`, but cargo applies a workspace lint
table to a member only if that member opts in — so a crate that never wrote those two lines sits
silently outside the policy. One crate did exactly that; it had no unsafe in it, "which is precisely
why no human was ever going to find it."
So the gate walks the derived member roster, asks each manifest whether it inherits, and ratchets.
`UNSAFE_EXEMPT` may shrink, never grow, and holds two argued rows: the FXCM bridge, whose `sys.rs`
is a C++ FFI shim, and `vike-core`, which uses `deny` rather than the workspace forbid so a per-site
`#[allow(unsafe_code)]` can carve out audited OS-mmap entry points while every other unsafe stays
denied. `UNSAFE_SITES` is the literal answer — **15 sites, 4 files, 2 crates** — counted per file
rather than flagged, because the interesting regression is an already-blessed file growing a
fifteenth site, which a boolean cannot see. `UNSAFE_ALLOW_SITES` is the separate carve-out ledger,
and the two agreeing is itself the check.
The same file pins the toolchain: the channel is written in six places, and
`the_toolchain_version_has_exactly_one_value` makes them agree by test. Measured 2026-08-06, the
boxes did **not** agree on their default toolchain, so the pin is the only reason those environments
compile the same code with the same compiler.
## One transport stack [#one-transport-stack]
`deny.toml` is the single scanner over every locked package, run with `all-features = true` so it
sees the full attack surface rather than the default build. `[advisories]` runs stricter than
cargo-deny's defaults — `yanked = "deny"`, `unmaintained = "all"` — with `ignore` entries as the
only escape hatch, each carrying a reason and a recheck trigger; `[sources]` denies unknown
registries and git dependencies outright.
`[bans]` denies wildcard versions and carries the machine-enforced "ONE transport stack" list: no
second HTTP client (`reqwest`, `isahc`, `curl` → `ureq`), no second WebSocket (`tokio-tungstenite`,
`async-tungstenite` → `tungstenite`), no alternative TLS (`native-tls`, `openssl` → `rustls`;
`aws-lc-rs` → `ring`), no second curve implementation (`secp256k1` → `k256`). Every entry is green
today and was *proven to bite* by temporarily adding a second client and reverting. Shared HTTP type
and parse libraries are deliberately not banned — banning them would redden the gate rather than
catch a real second stack.
`[licenses]` sets `private = { ignore = true }`, and that exemption is what keeps the gate green:
the licence is source-available rather than OSI-approved, so it has no SPDX identifier and could
never appear in the allow list. Free. Source-available under FSL-1.1; every release becomes
Apache-2.0 two years on.
`just deny` audits the source lockfile at merge. Its release-time twin `just audit-bin` builds a
shipped binary with an embedded SBOM via `cargo-auditable` and scans the *artifact* against RustSec
— what the distributable actually links.
Related: [Release gates](/docs/trader/developer/release-gates) ·
[Drift gates](/docs/trader/developer/drift-gates) ·
[Architecture](/docs/trader/concepts/architecture)
# Where it runs (/docs/trader/getting-started/deployment-targets)
One core, several front ends. Which one you install decides what you can do without touching
anything else, so the choice is worth a table before a download.
## The surfaces [#the-surfaces]
| Surface | Backtests | Paper | Live | Studio | Observe | Managed by | Needs |
| ------------------------------------------------ | ----------------------- | ---------------- | ----------- | ---------------------------------- | ----------------------------- | ------------------------- | -------------------------------- |
| Desktop app (`vike-desktop`, `vike-desktop.exe`) | only via a data service | no | no | remote store only | its only mode | you, on your desk | a GPU, a display, a running node |
| `vike-tradehub` | no | yes, the default | yes, opt-in | no | it is the node being observed | systemd, or the container | no GPU; a project folder |
| `vike-datahub` | yes — it serves them | no | no | it is what remote runs dispatch to | no | systemd, or the container | no GPU; a history store |
| `backtest` | yes, one process | no | no | no | no | a shell | no GPU; a history store |
| `vikeio/vike-tradehub` image | yes | yes | yes, gated | no | serves the node | Docker | a mounted project folder |
| `vikeio/vike-thin` image | only via a data service | no | no | remote store only | its only mode, in a browser | Docker | CPU for software rendering |
**The desktop app** ships as **one** build. `vike-desktop` declares no cargo features at all: it
holds no trading core, mounts no venue, links no DataFusion and takes `vike-data` with default
features — the `HistStore` trait and nothing behind it. What it draws comes from a node and a data
service over the wire, which is why it needs one running.
It drives the Studio remote-only, against the resolved datahub address — the explicit
`config.datahub_addr`, else a connected backend's own advertisement — and shows a
"Studio: no data store" label naming both fixes when neither answers.
[Observe mode](/docs/trader/gui/observe-mode) covers the modes; the sizes are on
[Requirements](/docs/trader/getting-started/requirements).
Until the 2026-09-09 rename the crate was `vike-app` and was compiled twice — a `fat` default with
a local trading core, DataFusion tick recording and the venue bridges, published as `vike-app-fat`,
and a `thin` observe-only client published as `vike-app-thin`. The `fat` resolution was deleted
along with the local core, so the surviving build is what `thin` described and the two suffixed
assets are gone with it. The container image keeps the name `vikeio/vike-thin` deliberately — the
word now describes the deployment shape, a GUI in a container against a remote node, rather than a
cargo feature.
**`vike-tradehub`** is the headless daemon: a strategy named in a TOML profile, mounted on the
production live core over the paper exchange by default, with zero real-money, credential or geo
risk and no live feed. The opt-in live variant mounts the same strategy on the real multi-venue
core with per-venue credential-gated execution and the venue's own feed, behind the
`tradehub_live` flag, the credential store, each venue's own second gate, the daemon's venue
allow-list (`LIVE_WIRED_VENUES`) and a venue-plus-symbol check against the core's wired-markets
table. Its observe/control server starts only when an address is configured — `config.tradehub_addr`
or `VIKE_TRADEHUB_ADDR`, unset by default, with the commented `127.0.0.1:7879` in the shipped
examples as the loopback address to use — and is authenticated with scoped HMAC node keys. The Linux
unit is `deploy/vike-tradehub.service`; the container is the same binary under `docker run`. No
daemon asset is published for Windows.
**`vike-datahub`** is the compute-to-data backtest server: a client ships a small profile over
a loopback TCP socket, the server runs the existing backtest engine next to the data and returns
only the report. It listens on `127.0.0.1:7878` by default and refuses a non-loopback bind unless
`VIKE_DATAHUB_ALLOW_PUBLIC_BIND=1` is set *and* a node key is configured; with neither
`VIKE_DATAHUB_OBSERVE_KEY` nor `VIKE_DATAHUB_CONTROL_KEY` in the credential store it
authenticates nothing, which is why it is meant to be reached over an SSH tunnel. The release
binary carries the `serve-datafusion` backend; a default build answers `--help` and reports it has
no backend. It is also what the Studio's [remote runs](/docs/trader/gui/studio/remote-runs) dispatch to.
**`backtest`** is the one-process path: it opens the history store itself, runs a profile, prints
a report, and persists a single run under `/user_data/runs//`. It also owns the
three ways a fresh store gets data — `--seed-demo`, `--fetch` and `--fetch-starter` — because the
tool that consumes history is the honest place for the command that creates some.
## The two images [#the-two-images]
`vikeio/vike-tradehub` carries the whole headless product as one multicall binary, `vike-backend`,
which
answers to every tool name through argv\[0]: `vike-tradehub` (the daemon the container starts by
default), `vike-cli`, `backtest`, `vike-datahub`, `vike-recorder`, `vike-study` and `tearsheet`
(`crates/vike/src/main.rs`'s `TOOLS` — the crate directory kept its old name). Measured on the release runner, the three DataFusion-linked
binaries are 128, 106 and 107 MB apiece; the multicall links that closure once. It runs one tool
per process, deliberately — every tool owns process-global state that assumes it is alone.
That the image is a first-class way to install — not a convenience beside a "real" one — is a
ruling rather than a slogan: it carries every headless tool because an earlier boundary keeping the
image deliberately partial was lifted, which `CHANGELOG.md`'s 0.1.17 entry records. The project
folder is a bind mount that outlives the image.
`vikeio/vike-thin` is the observe-only GUI as a container: the binary runs against a virtual
framebuffer on Mesa's lavapipe software rasteriser and is exported over VNC and noVNC, so it
behaves identically on Linux, Windows and macOS hosts because nothing in it touches host hardware.
The cost was measured on the same idle chart: the native binary drew about 22 fps at 10.9–12.5%
CPU; the container drew about 12 fps at 79–275% CPU. It is for the case a binary cannot serve —
a host with no published asset, a machine you do not control, occasional access — and it is not a
replacement for the native client.
The desktop app (`vike-desktop` needs GPU passthrough), IBKR (the release build enables no `ibkr`
feature and IB's own gateway would be yours to run), a linked FXCM daemon (`vike-tradehub-fxcm` is
a separate release asset; the image is built from the `vike-backend` asset alone),
and a Java runtime for Dukascopy (the image installs no package and stages no runtime tool). None
of those venues is wired in this image at all — `crates/vike-run/src/node.rs`'s `WIRED_MARKETS`
carries no IBKR or FXCM row without their cargo features, and Dukascopy has no
`vike_mount::make_engine` arm in any build — so no engine is mounted for them and nothing is
announced. A live profile that names one is refused at startup against `LIVE_WIRED_VENUES`, not
quietly demoted to paper.
## Choosing [#choosing]
* You want a chart and a first backtest on your own machine: the desktop app against a node, or
the backend image plus `backtest`. [Install on Windows](/docs/trader/getting-started/install-windows),
[Install on Linux](/docs/trader/getting-started/install-linux),
[Install with Docker](/docs/trader/getting-started/install-docker).
* You want a strategy running while your laptop is closed: `vike-tradehub` on a Linux box, native
or in the container, watched from the desktop app over an SSH tunnel.
* You want backtests to run where the history is: `vike-datahub` beside the store, driven by
`vike-cli backtest`, `sweep` and `walkforward` from anywhere.
# Your first chart (/docs/trader/getting-started/first-chart)
The desktop app opens on a live chart, so "your first chart" is mostly learning what is on it
and how to change it. Five minutes, no credentials — the default feed is Binance's public kline
stream.
## What you need [#what-you-need]
* `vike-desktop` — [Windows](/docs/trader/getting-started/install-windows) or
[Linux](/docs/trader/getting-started/install-linux) — and the GPU
[Requirements](/docs/trader/getting-started/requirements) names. It compiles in no local core and no
venue bridge, so it charts what a daemon streams it: [Observe mode](/docs/trader/gui/observe-mode).
* A running `vike-tradehub` node to point it at. There is no build that trades on its own.
* Internet access to Binance's public endpoints
(`crates/bridges/binance/src/market_feed.rs`'s `BINANCE_URLS`).
## 1. Launch [#1-launch]
```sh
vike-desktop
```
The window opens maximized and frameless with its own caption, titled `vike_trader_rust — live`.
With no saved workspace, `crates/vike-app-core/src/startup.rs`'s `plan` opens the default
layout: one `BTCUSDT` `1m` chart on Binance with an SMA overlay and RSI and MACD panes, its feed
ensured before the window is published. A saved workspace wins over that default on every later
start, and the last-used named layout wins over the plain file (`restored_workspace`).
## 2. Read what you are looking at [#2-read-what-you-are-looking-at]
The data path is `main.rs`'s header: feed threads → the core's ingest (lossless bar closes,
conflated forming bars and ticks) → the core-owned bar cache → an arc-swapped `CoreSnapshot`
→ the GUI's render model, with the GUI a lossy observer of core state. Per `(symbol, interval)`
the Binance feed runs one thread — a REST kline backfill as the warmup seed, then the public
kline WebSocket — closed bars on the lossless lane, forming updates and last-price ticks on the
conflating one (`market_feed.rs`'s header). So the right-most candle moves between closes; the
ones behind it are final.
The panel (`crates/vike-chart/src/chart/mod.rs`'s header): a right price axis with a price-tag
chip, a time axis with an hourly grid, a dashed last-price line, a crosshair with a time tag,
bottom nav buttons (− + ‹ › ⟳) plus **Auto**, and a style selector. Charts follow the live edge
and auto-fit y out of the box (`vike_chart::interact::FollowLive`): appended bars keep the view
pinned right, panning or zooming away disengages, Reset/Auto — or panning back — re-engages.
`ChartStyle::ALL` holds 20 styles, from candles through Renko, Kagi and Point & Figure to
Footprint.
Everything else sits on one title bar (`crates/vike-desktop/src/chart_window.rs`'s `title_bar`):
the style menu, the symbol and interval buttons, the sync-group chip, **ƒx** (indicators),
**OF** (CVD / Volume Profile / tick size), **Cmp** (compare overlays), the LIVE badge, and the
clone / minimize / maximize / close controls.
## 3. Pick a symbol [#3-pick-a-symbol]
Click the symbol. The popup opens with a focused **Search symbol** box, asset-class tabs — All,
then Stocks, Forex, Crypto, Perps, Options, Futures, Indices, Prediction (`PICKER_TABS`) — and a
venue button cycling All → binance → bybit → okx → hyperliquid → All (`PICKER_VENUES`). Results
come from `vike_catalog::Catalog::search`, up to 60 rows: exact symbol first, then symbol
prefix, base prefix, substring; ties broken by quote (USDT, USDC, USD, BTC, others) and then
catalog order. An empty query returns the filtered head, so the list is useful before you type.
Clicking a row sets symbol *and* venue together and carries the asset class, so a non-spot
symbol routes to its native product feed; a non-Binance chart shows a `VENUE:` prefix on the
symbol button.
The catalog is fetched once, off the UI thread (`main.rs`'s `spawn_catalog_fetcher`): every
wired venue's `CatalogProvider` contributes its universe, a failing provider contributes
nothing, and the credential-gated ones (Alpaca, OANDA, cTrader) yield nothing without keys.
Until it lands the popup shows the quick-picks `BTCUSDT`, `ETHUSDT`, `SOLUSDT` (`SYMS`),
Binance symbols that reset the venue to Binance.
`crates/vike-app-core/src/feed_lifecycle.rs`'s `ensure_feed_on` doc: the app registers live
clients for binance, bybit, okx, aster, hyperliquid and polymarket, while the picker searches a
twelve-venue catalog. Pick an alpaca, oanda, dukascopy, fxcm or ctrader instrument and the
window lands on a venue with no feed — it paints nothing and warns once. Perps, futures and
options chart only on okx, binance, bybit, aster and hyperliquid; deribit and polymarket rows of
those classes skip the subscribe (`venue_routing::venue_bar_instrument`).
## 4. Change the interval [#4-change-the-interval]
Click the interval: `1s`, `1m`, `5m`, `15m`, `1h` are venue klines (`IVLS`); below the
separators, `100t` / `500t` / `1000t` are tick-count bars and `1v` / `10v` / `100v` are
volume-threshold bars, both built client-side from the trade tape (`TICK_IVLS`, `VOLUME_IVLS`).
## 5. Add an indicator [#5-add-an-indicator]
**ƒx** opens the indicator picker — search, category tabs, favourites, your own installed
studies, and the "On this chart" list with a per-study settings ⚙ (`tool_views::fx_picker`);
the [catalog](/docs/trader/indicators) lists what it offers. **OF**'s CVD and Volume Profile toggles
make the window lazily subscribe the trade feed (`WinState::orderflow_on`); **Cmp** adds a
percent-normalized overlay of another symbol, and the first overlay flips the price scale to
Percent because overlays draw only there (`WinState::add_compare`).
## More windows, and saving [#more-windows-and-saving]
File ▸ New chart window cascades a fresh chart (`crates/vike-app-core/src/workspace/menu.rs`'s
`menu_bar`; the Window menu holds the arrange verbs). The title bar's clone copies the source
window's symbol and interval.
`crates/vike-app-core/src/window_spawn.rs`'s header pins it: a clone keeps symbol and interval
but not `venue`, so cloning an OKX chart yields a window pointed at Binance carrying an OKX
symbol, subscribed by nobody. Open a new window and pick the symbol again instead.
File ▸ Save Workspace writes the chart layout — symbol, interval, style, geometry, scale,
options, panes, indicators — to `/settings/state/workspace.json` (`$VIKE_WORKSPACE`
overrides), restored at startup; File ▸ Save layout as… keeps named layouts under
`layouts/.json` beside it (`crates/vike-app-core/src/workspace/persist.rs`). Tool windows
are not saved; the launcher recreates them. View ▸ Timezone and File ▸ Export chart image… are
the other two menu verbs worth knowing (`workspace::menu::MenuResult`).
## Verify [#verify]
The last candle changes between bar closes and the dashed last-price line tracks it; the
right-most close arrives on the interval you picked.
## Where to next [#where-to-next]
* [Quickstart: first paper mount](/docs/trader/getting-started/quickstart-paper) — the Trade window
beside this chart.
* [The chart](/docs/trader/gui/chart) · [Workspaces](/docs/trader/gui/workspace) ·
[Order flow](/docs/trader/orderflow) · [The desktop app](/docs/trader/gui).
## Troubleshooting [#troubleshooting]
* **The chart is empty after picking a symbol.** The venue has no feed, or the product has no
bar feed on that venue — the callout in step 3. Pick the same instrument on binance, bybit,
okx, aster or hyperliquid.
* **The picker shows only three symbols.** The catalog has not loaded or every provider failed
(no network); the quick-picks are the fallback. Only Deribit's catalog is linked into this
binary, by design — every other provider answers over the wire.
* **A cloned window never paints.** It dropped its venue — the callout above.
* **The saved layout did not come back.** `restored_workspace` skips the restore under
`VIKE_SHOT`; otherwise confirm `/settings/state/workspace.json` exists — File ▸ Save
Workspace is its only writer.
More in [Troubleshooting](/docs/trader/troubleshooting).
# Getting started (/docs/trader/getting-started)
Vike Trader is one execution core with several faces. Before installing anything, the question worth
answering is which face you want, because the install differs and so does what you do first.
Nothing on this path can place a real order. Every venue starts on the paper exchange, and arming one
is a deliberate act you take later, in a file, per venue.
## Which surface [#which-surface]
## The order to read this in [#the-order-to-read-this-in]
**Understand it, then size it.** [What is Vike Trader?](/docs/trader/getting-started/what-is-vike-trader)
is the two-minute tour; [Requirements](/docs/trader/getting-started/requirements) carries the measured
numbers for GPU, memory, disk and network per surface — and the fact that there is no macOS build.
**Then install.** [Docker](/docs/trader/getting-started/install-docker) is the shortest path and the one
that needs least from your machine. [Windows](/docs/trader/getting-started/install-windows) and
[Linux](/docs/trader/getting-started/install-linux) install the release binaries directly; each ends with
one project folder holding settings, state, data and logs together.
**Then run something.** Two quickstarts, and they answer different questions.
[First backtest](/docs/trader/getting-started/quickstart-backtest) needs no credentials at all — it
scaffolds the examples, seeds a synthetic tape and prints a report.
[First paper mount](/docs/trader/getting-started/quickstart-paper) brings up a live market feed with every
venue on the paper exchange, so you can watch real prices and place an order that reaches nothing.
[Your first chart](/docs/trader/getting-started/first-chart) does the same in the desktop app.
**Then keep it current.** [Updating](/docs/trader/getting-started/updating) covers what a release is, how
to tell which build is running, and what a version bump does to a write-ahead journal.
## What comes after [#what-comes-after]
Once something runs, the tree splits by what you are trying to do. The
[concepts](/docs/trader/concepts) explain how the thing works — the execution core, the venue seam, the
journal, the gates. The [guides](/docs/trader/guides) are task-shaped: arm a venue, record a feed, replay
a session, triage a stuck order. The [tutorials](/docs/trader/tutorials) are worked runs end to end, with
every number pasted from a real one.
Installing arms nothing. A venue trades live only once its per-venue ceiling in `policy.toml` says so
**and** its credentials are present — the ceiling is read first, so a box with no `[venues]` table
mounts everything paper whatever else is configured. See
[risk and arming](/docs/trader/concepts/risk-and-arming) before you go looking for the switch.
# Install with Docker (/docs/trader/getting-started/install-docker)
The backend image carries the whole headless product — the daemon, the CLI, the runner, the data
service, the recorder and the reporting tools — as one container. Your settings, credentials,
state and data live in a project folder on the host, mounted in; the image contains no credential,
and a fresh folder mounts every venue on paper. Expect about ten minutes: a pull, three commands
in a folder, one `docker run`.
## Requirements [#requirements]
Docker or Podman on any host; see [Requirements](/docs/trader/getting-started/requirements) for the
per-OS notes. On Windows use PowerShell or a WSL shell for every command below, never Git Bash —
MSYS path conversion rewrites the container side of `-v`, so the daemon finds no project at all
and every venue silently stays on paper.
## Steps [#steps]
### 1. Pull the image [#1-pull-the-image]
```sh
docker pull ghcr.io/vike-io/vike-tradehub: # GHCR — named first, on purpose
docker pull vikeio/vike-tradehub: # Docker Hub, the same bytes
```
The release job that publishes these images tags each one with the release version alone —
`vike-tradehub:`, and no moving `latest` — so a pull always names a version you chose.
GHCR is named first because publishing there stores no secret anywhere: that job pushes with its
own token, scoped to writing packages, while the Docker Hub name is the shopfront, the default
registry, so that pull needs no host prefix. `CHANGELOG.md`'s 0.1.16 entry records both
destinations and why each is reached the way it is.
Inside is one multicall binary, `vike`, answering to `vike-tradehub` (what
the container starts by default), `vike-cli`, `backtest`, `vike-datahub`, `vike-recorder`,
`vike-study` and `tearsheet` (`crates/vike/src/main.rs`'s `TOOLS`). The binaries are the
release's own bytes, copied in and never compiled inside `docker build`, which is why `--version`
can still name the commit.
### 2. Make the project folder [#2-make-the-project-folder]
```sh
R=/srv/vike-trader # your folder — substitute it everywhere below
mkdir -p "$R/settings/state" "$R/data"
docker run --rm ghcr.io/vike-io/vike-tradehub: --template > "$R/settings/tradehub.toml"
```
* `settings/state` is the daemon's one writable directory. The `HALT` kill switch lives there and
nothing creates it lazily; without it the switch is dead until someone reaches for it.
* `settings/tradehub.toml` is the daemon profile — venue, symbol, strategy. `--template` is a verb
of the entrypoint, not the daemon: it prints the repository's own template to stdout and nothing
else, so the redirect is safe. Without the file the daemon exits (`bad profile : No such
file or directory`).
* `data/` is the store, which must outlive a container.
The entrypoint refuses to start without the mount or `settings/`, then reports everything else
wrong inside the folder in one numbered list (`deploy/docker/entrypoint.sh`'s `check_project`),
and probes that `settings/state` keeps flock-serialised appends — the one filesystem shape no code
change rescues.
### 3. Run it [#3-run-it]
```sh
docker run -d --name vike-tradehub \
--user "$(id -u):$(id -g)" \
-v /srv/vike-trader:/project \
--restart on-failure \
--stop-timeout 10 \
--memory 2g \
--cap-drop ALL \
--security-opt no-new-privileges \
ghcr.io/vike-io/vike-tradehub:
```
The node server is opt-in and the `docker run` above starts no listener: turn it on with
`-e VIKE_TRADEHUB_ADDR=0.0.0.0:7879`, publish the port to loopback — `-p 127.0.0.1:7879:7879`,
never `-p 7879:7879` — and reach it over an SSH tunnel. The image sets
`VIKE_TRADEHUB_ALLOW_PUBLIC_BIND=1` because a container must bind the wildcard inside its
namespace; on that wildcard address you will see one `ProceedExposed` line at start, and it is
true.
**On Docker Desktop (Windows/macOS)** keep `settings/` a bind mount and put the two writable paths
on named volumes: the mmap-backed journal suite measured 389 s over the host passthrough against
1.23 s on a volume.
```sh
docker volume create vike-state && docker volume create vike-data
docker run -d --name vike-tradehub \
-v /srv/vike-trader:/project:ro \
-v vike-state:/project/settings/state \
-v vike-data:/project/data \
--read-only --tmpfs /tmp \
--restart on-failure --stop-timeout 10 --memory 2g \
--cap-drop ALL --security-opt no-new-privileges \
ghcr.io/vike-io/vike-tradehub:
```
Drop `--user` with named volumes (a volume is owned by whoever first writes it); keep it with a
Linux bind mount, which carries host ownership.
Nothing in a daemon profile arms a venue. Going live needs the venue's keys in
`settings/secrets.env` — absent credentials are the live gate — and the per-venue ceiling in
`policy.toml` raised from its `paper` default, which can only ever refuse. Start on paper, read
the snapshot summaries, then read [Arm one venue for demo](/docs/trader/guides/arm-a-venue).
## Verify [#verify]
```sh
docker run --rm -v /srv/vike-trader:/project --user "$(id -u):$(id -g)" \
--entrypoint /opt/vike/bin/vike-cli ghcr.io/vike-io/vike-tradehub: config check
docker run --rm --entrypoint /opt/vike/bin/vike-tradehub ghcr.io/vike-io/vike-tradehub: --version
```
The first judges the settings tree and the credential store and says *what* is wrong; it does not
judge `tradehub.toml`, which the entrypoint checks itself before this call. The second prints the
commit the binary was built from. The kill switch is a file, so it works from the host or through
`docker exec` whether or not the runtime answers: `touch /srv/vike-trader/settings/state/HALT`
stops new orders and `rm` resumes.
## Next steps [#next-steps]
A fresh store is empty, and every tool over it answers with an empty result that looks exactly
like a broken install. Two commands close it, both through the image's other tools:
```sh
docker run --rm -v "$R:/project" --entrypoint /opt/vike/bin/vike-cli ghcr.io/vike-io/vike-tradehub: init
docker run --rm -v "$R:/project" --entrypoint /opt/vike/bin/backtest ghcr.io/vike-io/vike-tradehub: --seed-demo
```
Then [Quickstart: first backtest](/docs/trader/getting-started/quickstart-backtest). To watch the daemon
from a browser — with its node server turned on, as above — the viewer image runs
`vike-desktop --observe` on a virtual display:
```sh
docker run --rm -p 127.0.0.1:6080:6080 \
-e VIKE_TRADEHUB_ADDR=host.docker.internal:7879 \
-e VIKE_TRADEHUB_OBSERVE_KEY= \
-e VIKE_THIN_VNC_PASSWORD= \
ghcr.io/vike-io/vike-thin:
```
Open `http://localhost:6080`; it connects on its own. Publish that port to loopback only — VNC
carries mouse and keyboard, so whoever reaches it drives a GUI authenticated to a trading node —
and prefer the native `vike-desktop` binary whenever you can run one.
## Troubleshooting [#troubleshooting]
* **Every venue on paper, nothing in the log.** The mount or `settings/` was missing, or `docker
run` came from Git Bash. The entrypoint names the `-v` flag; see
[Troubleshooting](/docs/trader/troubleshooting).
* **Polymarket dials fail at the SOCKS connect.** The egress proxy defaults to the container's own
loopback (`crates/bridges/polymarket/src/egress.rs`'s `proxy_url`). Set
`-e POLY_PROXY_HOST=host.docker.internal`, a full `-e POLY_SOCKS_PROXY=...`, or
`-e POLY_PROXY_ENABLED=false` if you are not geo-blocked; use `socks5h`, never `socks5`.
* **The log lands in your folder and grows.** Keep the baked `VIKE_LOG_FILE_LEVEL=warn`; the file
layer defaults to `trace`.
* **`docker stop` cuts the resting-order cancel sweep short.** If you raise `shutdown_deadline_ms`
in `tradehub.toml`, raise `--stop-timeout` in the same breath.
* **`'/bin' holds files this image did not install`.** The entrypoint refuses to replace
a `bin/` it did not stamp; move the files aside or set `VIKE_CONTAINER_ADOPT_BIN=1` if you mean
it.
# Install on Linux (/docs/trader/getting-started/install-linux)
Linux gets the whole product as native binaries. Expect ten minutes for the headless set and a
first backtest; add the desktop app if the box has a display.
## Requirements [#requirements]
x86-64 with glibc 2.39 or newer — the assets are dynamically linked against the release runner's
Ubuntu 24.04, and an older host fails at exec, not at install. The desktop build additionally
wants a Vulkan-capable GPU, an X11 or Wayland session and a font from the candidate set.
[Requirements](/docs/trader/getting-started/requirements) has the measured numbers.
## Steps [#steps]
### 1. Choose a project root [#1-choose-a-project-root]
One directory on a disk with room, owned by the user that will run the binaries, holding `bin/`,
`settings/` (with `state/` and `state/logs/`), `user_data/` and `market_data/`. The root is a
parameter, not a convention.
```sh
R=/srv/vike-trader
mkdir -p "$R/bin" "$R/settings/state/logs" "$R/market_data"
```
Every shipped unit sets `ProtectHome=yes`, which replaces `/home` with an empty directory inside
the unit's namespace, and `ReadWritePaths=` does not rescue it. The daemon then starts with no
settings and no credentials — `No such file`, not `Permission denied` — every venue silently on
paper. Measured, twice.
### 2. Download and verify [#2-download-and-verify]
From the release: `vike-cli`, `vike-tradehub`, `backtest`, `vike-datahub`, `vike-recorder`,
`vike-study`, `tearsheet`, the desktop `vike-desktop`, and `SHA256SUMS`. The
manifest is the upload list — an asset cannot be published without a checksum — so verify the
lines for what you took:
```sh
cd "$R/bin"
grep -E ' (vike-cli|vike-tradehub|backtest|vike-datahub)$' SHA256SUMS | sha256sum -c
chmod +x vike-cli vike-tradehub backtest vike-datahub
```
Every Linux binary embeds its dependency SBOM (`cargo auditable`), and `--version` prints the
commit it was built from through `vike_buildinfo::version_line`.
### 3. First commands [#3-first-commands]
```sh
cd "$R"
bin/vike-cli --version
bin/vike-cli config check # the verdict on settings/ and the credential store: exit 0 = nothing broken
bin/vike-cli init # scaffold user_data/ with runnable examples, and print the map
bin/backtest --seed-demo # the synthetic demo tape, under venue `demo`, into market_data/hist
```
`vike-cli init` writes only what is absent and never touches a file you edited. `config check`
judges the four settings files and the store; an absent credential store is fine, because absent
credentials are the live gate. Every binary resolves the project by walking up from the working
directory, so run them from `$R`, or set `VIKE_SETTINGS_DIR=$R/settings` to name it outright.
### 4. The desktop app, if you want it [#4-the-desktop-app-if-you-want-it]
```sh
bin/vike-desktop
```
The binary carries no hard dependency on libX11 or libwayland-client; winit loads them at window
creation, so a missing library surfaces as `XNotSupported(LibraryOpenError)` after the version
line. Install a font it can find — `/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf` is the floor
`vike_app_core::fonts` names — or the window controls render as empty boxes.
### 5. The daemon under systemd, when you want it unattended [#5-the-daemon-under-systemd-when-you-want-it-unattended]
The shipped unit `deploy/vike-tradehub.service` carries the placeholder root `/srv/vike-tradehub`;
substitute yours, check nothing was missed, install it as a *system* unit with a `User=` line, and
create `settings/state` first — under `ProtectSystem=strict` a missing `ReadWritePaths=` target
fails the unit with `status=226`.
```sh
cp settings/tradehub.example.toml "$R/settings/tradehub.toml" # the source tree's template; the image prints the same bytes with --template
sed "s,/srv/vike-tradehub,$R,g" deploy/vike-tradehub.service > /tmp/vike-tradehub.service
grep -n /srv/vike-tradehub /tmp/vike-tradehub.service # must print NOTHING
sudo install -Dm644 /tmp/vike-tradehub.service /etc/systemd/system/vike-tradehub.service
sudo systemctl daemon-reload && sudo systemctl enable --now vike-tradehub
```
The unit runs `ExecStartPre=/bin/vike-cli config check`, so `vike-cli` must sit beside the
daemon or the unit fails with `status=203/EXEC`; it sets `VIKE_LOG_FILE_LEVEL=warn` and no
`VIKE_LOG_DIR`, so the log lands inside the one write grant; and it points `EnvironmentFile=` at
`/.env`, never at the credential store. `sudo systemctl stop` is a graceful stop: SIGTERM
raises the same flag the interactive `shutdown` word raises and the ordinary teardown runs, as long
as `TimeoutStopSec=` stays above the profile's `shutdown_deadline_ms`.
## Verify [#verify]
```sh
bin/backtest --list # the compiled strategy names
journalctl -u vike-tradehub | grep '"kind":"ready"' # the daemon's ready banner, with its mode
```
The daemon logs the identity line first and its `settings dir:` line after it; with recording on,
its store open logs `tick store root resolved … rung=project` — read `rung=`, because `exe-dir`
means no project was found above the working directory.
## Next steps [#next-steps]
* [Quickstart: first backtest](/docs/trader/getting-started/quickstart-backtest) — the demo tape you
just seeded, through the standalone runner or the data service.
* [Quickstart: first paper mount](/docs/trader/getting-started/quickstart-paper).
* Dukascopy needs the release's `jforex-bridge.jar` at `bin/jforex/jforex-bridge.jar` and a JRE
under `bin/jre/`; LightGBM needs both `bin/lightgbm/lightgbm` and its `PROVENANCE` file. Both are
Release assets, published beside the binaries and covered by the same `SHA256SUMS`; a source
checkout also ships a helper that stages and verifies them into exactly that layout. See [Updating](/docs/trader/getting-started/updating).
## Troubleshooting [#troubleshooting]
* **`version 'GLIBC_2.39' not found` at exec.** The host's glibc is older than the runner's.
* **`status=226` or `203/EXEC` from systemd.** `settings/state` is missing, or `bin/vike-cli` is.
* **Empty boxes instead of window controls.** No font from the candidate set is installed.
* **`rung=exe-dir` in the daemon's store line.** No project above the working directory; run from
the root or set `VIKE_SETTINGS_DIR`. More under [Troubleshooting](/docs/trader/troubleshooting).
# Install on Windows (/docs/trader/getting-started/install-windows)
Windows gets the desktop app, `vike-desktop.exe`, and `vike-cli.exe`, the command-line surface.
Both are cross-built for `x86_64-pc-windows-gnu` in the release's own `windows` job and need no
runtime shipped beside them. Expect five minutes: a download, a checksum, a folder, a double-click.
The release used to publish `vike-app.exe` and `vike-app-thin.exe` — two feature resolutions of one
crate. There is one. The crate declares no features at all, the desktop mounts no venue and runs no
local trading core, and `vike-desktop.exe` **is** that build. A bookmark or runbook still naming
either old file names an asset the release no longer produces.
## Requirements [#requirements]
A GPU with a Vulkan driver — the released binary was run on a Windows box and enumerated its real
Intel GPU through Vulkan — and the Segoe UI faces under `C:\Windows\Fonts`, which
`vike_app_core::fonts` looks for first. Details on [Requirements](/docs/trader/getting-started/requirements).
No daemon, runner or data-service `.exe` is published: the release's host lane builds those for
Linux only. To run the backend on a Windows box, use the container —
[Install with Docker](/docs/trader/getting-started/install-docker). `vike-cli.exe` is the exception and
does ship, which is what lets an MCP client on this box launch `vike-cli mcp` as a local subprocess
— see [Client setup](/docs/ai/trader/setup). It still needs something to talk to: its backtest tools want
a `vike-datahub` server and its live tools a running node, neither of which has a Windows binary.
## Steps [#steps]
### 1. Download and verify [#1-download-and-verify]
Take `vike-desktop.exe`, `vike-cli.exe` if you want the command line, and
`SHA256SUMS` from the release. The manifest
covers every asset, and the Windows lines are in it because the `windows` job wrote its own part
and the publish job verified that part before merging it. Check the file:
```powershell
(Get-FileHash .\vike-desktop.exe -Algorithm SHA256).Hash
Select-String vike-desktop.exe .\SHA256SUMS
```
The Windows job builds with `cargo build` and copies the result straight into the release; there
is no code-signing step, so Windows shows its SmartScreen prompt on first launch. The checksum
above is the verification, not the prompt.
### 2. Make the project folder [#2-make-the-project-folder]
Every setting, credential and program-written file lives in one folder, and the binary finds it by
walking *up from the working directory* for the nearest `settings\` directory
(`crates/vike-model/src/state_path.rs`'s `PROJECT_SETTINGS_DIR`).
```powershell
$R = 'C:\vike-trader'
New-Item -ItemType Directory -Force "$R\bin", "$R\settings\state\logs", "$R\market_data" | Out-Null
Copy-Item .\vike-desktop.exe "$R\bin\"
```
`bin\` is where the tools a project runs live (`vike_model::state_path::PROJECT_BIN_DIR`), so the
executable sits there and the walk from `bin\` finds `settings\` one level up. If you would rather
launch from anywhere, set `VIKE_SETTINGS_DIR=C:\vike-trader\settings`: it names the directory
outright and beats the walk.
### 3. First launch [#3-first-launch]
Start it from the project root — a shortcut whose *Start in* is `C:\vike-trader`, or a terminal
`cd`'d there:
```powershell
cd C:\vike-trader
.\bin\vike-desktop.exe
```
The window opens maximised, titled `— live`. Before it draws anything the app runs the one startup
sequence (`vike_boot::boot`): refuse a removed environment variable, resolve `settings\` once, load
the credential store, load the four settings files, then log its build identity and what it
resolved. With an empty `settings\` it starts on compiled-in defaults and every venue on paper —
absent credentials are the live gate. To point it at a node instead, name one:
```powershell
.\bin\vike-desktop.exe --observe :7879
```
It takes `VIKE_TRADEHUB_OBSERVE_KEY` from `settings\secrets.env`, and from the environment when
the store has none (`vike_app_core::backend_registry`'s `fill_node_keys_from_env` — a gap-fill,
never an override: a store entry wins). The key must match the daemon's byte for byte. Leaving the
address off is not an error: it falls through to the configured node and then to the local default,
the window opens, and the status bar says whether it connected.
## Verify [#verify]
The rolling log lands at `settings\state\logs\`, prefixed `vike-desktop`
(`crates/vike-desktop/src/main.rs`'s `file_prefix`, not the executable name); its first line is the
identity line — `git clean, built