# 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 `** — a single-knob quick opt-in at the default cadence, with `VIKE_JOURNAL_SNAPSHOT_EVERY=` optionally overriding the snapshot cadence. Consulted **only** when `VIKE_RUN_PROFILE` is unset. Neither set means no journal. The same directory is reachable from settings as `config.journal_dir`, with `VIKE_JOURNAL_DIR` as its environment spelling — see [environment variables](/docs/trader/reference/environment). Run a session — paper is fine, and is the right place to start — then stop it cleanly so the shutdown snapshot is written. ## Replay it [#replay-it] The `replay_journal` binary (its reference page is [replay-journal](/docs/trader/reference/binaries/replay-journal)) takes the journal directory and nothing else. It prints four things: ```text journal records : snaps verified : final state hash : <16 hex digits> engine /: open orders, positions, balance ``` The first line is labelled honestly in the source: `records` is the **total** source journal record count, not the length of the tail that was re-folded. A failure prints `REPLAY FAILED:` with the error and exits non-zero. ## What it actually does [#what-it-actually-does] Two entry points share one engine. `replay_offline` — the one the binary calls — restores from the journal's **first** `Snap`, re-folds the whole tail, and fences the reproduced final hash against the source's **last** `Snap` hash. `restore_from_journal` is the crash-restart twin: it restores from the **latest** snapshot, re-folds the usually-empty crash tail, and deliberately has **no fence**, because it is a restore rather than a proof. Restoring from the first checkpoint is the load-bearing choice. Restoring from the latest would leave nothing to replay on a clean run — its exit snapshot already reflects everything — which is a worthless fence. Cadence snapshots are taken *after* the fold, so every command before the first snapshot is already folded into it and every command after is re-folded forward, re-deriving the whole post-checkpoint history. The replay core is the real one, with three substitutions: * a pure no-op `ReplayClient`, because the original venue events are already journaled as `Ingest::Event` records and get re-pumped — a client that *also* emitted events would double the state; * a `QueueClock` replaying the tail's recorded `now_ms`, so timestamp-derived state such as an order's creation time matches; * a temporary journal whose snapshot cadence is effectively infinite, so only the exit snapshot fires. The fence is **final-hash equality**, not per-record comparison: the replay core mints its own sequence numbers, so a per-sequence comparison is impossible, and re-folding from the first checkpoint reproducing the exact final state is the essential guarantee. ## What the fence covers now [#what-the-fence-covers-now] It used to check exactly one thing — `state_hash(&engines)` — while the restore hands a restart three more values nothing looked at. A fold bug that resurrected a consumed conditional arm, dropped a live one, mis-scoped a mass cancel or rewound a counter reproduced a bit-identical engine hash and passed. `replay_offline` now adds two fences over the same oracle: * **the resting conditional books**, through `conditionals_hash` against the ones the source's last snapshot recorded — on a v8-or-later journal only, since `Snap.conditionals` was defaulted in at v8 and an older snapshot reads its books back empty; * **the `coid_seq` / `arm_seq` counters**, as **floors rather than equalities** (`fence_floor`). An overshoot is legal and deliberately produced — a mark-refused tail trailing arm, or a pre-v7 base snapshot — while an undercount is the id-reuse bug worth catching. Four exclusions remain, each argued rather than overlooked: `now_ms` and the account snapshot's marks, which non-journaled market data mutates on every message; a trailing arm's ratcheted extreme, the books' analogue of those marks; the GTD sweep's fire-once suppression set, which cannot move the hash in either direction; and the publish/cadence bookkeeping — `dirty`, `journaled_since_snap` and the bounded recent-events ring — which decides only when a snapshot or GUI publish fires, never what it contains, and is not restored at all, so a fence would have nothing to compare. ## Reading a failure [#reading-a-failure] `ReplayError`'s variants are a triage table, and two distinctions matter more than the rest. A journal whose **read** stopped at a torn frame reports `Truncated { cause }`, returned *instead of* `HashMismatch` and only where that would have been returned. The records the fences see are then a prefix of what the session wrote, so the fence target is not the exit snapshot but whichever mid-session checkpoint survived — and re-folding past a mid-session checkpoint necessarily produces a different hash. `HashMismatch` reads as "the engine diverged, this journal cannot be trusted"; for a torn tail that is a misreading of the ordinary consequence of a crash. A truncated journal whose fences all pass still succeeds exactly as before. `RestoreMismatch { field, expected, got }` is the other split: the engine state matched, but something else the restore hands back did not — a book or a counter. Different cause, completely different triage path. `Unsupported` covers the two shapes this replay does not handle: a multi-engine base snapshot, and a tail carrying watchdog-tick records, which the submit-acknowledgement timeout and both dead-man switches still arm and which are not deterministic functions of the journal. `Empty` means no snapshot at all — a truncated or absent journal, since a clean shutdown always writes one. `Io` is a real disk fault, kept distinct so a determinism tool never folds one into `Unsupported`. ## Upgrading between sessions [#upgrading-between-sessions] Segments stamp a format version, and this build reads and **resumes** anything in the accepted range rather than rejecting it, so upgrading a binary never costs an operator their live journal. The rule that makes the range safe is that a version step may only **add** a record variant or a defaulted field. Determinism is unaffected by reading an older journal: a v4 journal replayed by a newer binary yields exactly the record sequence a v4 binary saw, because the added variants are ones v4 never wrote, and the additive records that do exist are replay-neutral observations the tail extraction ignores. What an older journal costs is fence COVERAGE rather than determinism: below v8 the conditional-books fence above is skipped, while the hash fence and the counter floors still run. Next: [the command journal](/docs/trader/concepts/journal) for the format and the sync policy, [replay](/docs/trader/concepts/journal/replay) for the reference page, and [stop, liquidate, restart](/docs/trader/guides/stop-liquidate-restart) for what a restart replays and what it does not. # Monte Carlo and overfitting over a finished run (/docs/trader/tutorials/monte-carlo-and-overfitting) A finished run gives you one equity path and one Sharpe ratio. Neither is a result on its own: the path is the order the trades happened to land in, and the Sharpe is the maximum of however many configurations you searched. `vike-analytics` answers both questions, and this tutorial runs them over a run you already have. ## Where the inputs are — and are not [#where-the-inputs-are--and-are-not] A finished single run persists a common `manifest.json` beside `report.json` under `/user_data/runs//`. But `BacktestReport` is deliberately **compact** — it is the bin's human table, its `--json` schema and the sweep's ranking source, not an everything-drawer — and it carries no trade list and no equity curve. Both families below read `BacktestResult`'s `trades` and `equity_curve`, so they run against the result **in process**, from a caller that ran the backtest, not against the persisted report. And only the single-run path persists at all: a sweep, a euler refinement or a TPE run answers with a `SweepReport`, a different document and a run kind of its own, and writes nothing. Spell the Monte Carlo module `vike_analytics::montecarlo`. `vike-backtest` re-exports most of that crate's modules at its own root but deliberately not this one — re-adding a name is only correct once a call site needs the shorter spelling, and none ever has. ## How much of the path was ordering [#how-much-of-the-path-was-ordering] `mc_resample(trade_pnls, start_equity, n_sims, seed, method)` builds `n_sims` synthetic equity paths from the observed trade list. Two methods, each documented by what it preserves: * **`Shuffle`** reorders the trade list, preserving the exact multiset of P\&Ls per path. Terminal equity is therefore the same on every path; what varies is the *shape* — 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 too. You get back `terminal` (each path's final equity), `max_drawdowns` (each path's max-drawdown fraction, through the shared `metrics::max_drawdown`) and `curves_sample` — at most 20 full curves, thinned evenly across the simulations, for plotting. `confidence_bands(curves, qs)` turns equal-length curves into per-step percentile bands. `mc_summary(trade_pnls, start_equity, n_sims, seed, ruin_pct)` is the one-call version, running the **shuffle** method and returning terminal equity at the 5th, 50th and 95th percentiles, max drawdown at the 50th and 95th, `prob_loss` (the share of paths ending at or below the starting equity) and `risk_of_ruin` (the share ending at or below `ruin_pct × start_equity`). Two things to decide before you read a number from it. **Which P\&L you feed.** `Trade.pnl` is *gross price* P\&L and `Trade.fees` is the round-trip cost, carried separately. Feeding `pnl` gives you a pre-cost distribution; feeding `pnl − fees` gives you the one you can spend. Pick deliberately and say which in the write-up. **What the seed does and does not promise.** The module is seeded for run-to-run reproducibility **within this implementation**, and it says outright that there is no golden fixture for it: porting CPython's exact random number generator was ruled out as not worth it for one file, so no exporter ever froze one. The provenance survives the decision — when the two implementations were run side by side, the same seed produced statistically equivalent, not bit-identical, paths. ## Whether the Sharpe survives the search [#whether-the-sharpe-survives-the-search] `crates/vike-analytics/src/overfit.rs` has one sanctioned entry: `sharpe_moments(equity_curve)`, which produces the four inputs everything else takes — `sr_per_obs`, `n_obs`, `skew`, `kurt`. It exists because two unit conventions each carry a footgun, and the annualized-Sharpe one has already shipped as a real bug. Never hand `overfit::` an annualized figure; the annualized Sharpe is a display quantity for a different reader. From one run you can compute the probabilistic Sharpe and nothing more — the deflated Sharpe and the probability of backtest overfitting both need the **set of trials**. That is the whole point, and `audit_selection` is the function that refuses to let you forget it. `effective_n_trials` derives its count from the trials it is fed, and `pbo_cscv`'s matrix columns must be the full candidate set. Feed either only the *survivors* of a search and you deflate against a trial count that understates by orders of magnitude — the gates will happily pass garbage, because they are only as good as the trial count they are given. The failure is silent and easy to commit: pick the best pair out of fifty, hand its curve alone to `deflated_sharpe_ratio`, read a reassuring number. `audit_selection` takes the full candidate arrays **plus the selected index** precisely so the mistake is visible: a survivors-only call reports `n_candidates == 1` and says so in the verdict. Inconsistent inputs return `None` — an unauditable selection is not a passing one. The audit reports `selected_trades` beside the Sharpe, and its doc says why: a mean-reversion family dies by **starvation** — too few divergences — before it dies by unprofitability, and a Sharpe-ranked sweep cannot see that. It matters most for pair selection, a maximum over `N(N−1)/2` correlated candidates, a search far larger than any parameter sweep and one that happens *before* the sweep. ## Reading the verdict [#reading-the-verdict] `overfit_verdict(pbo, deflated_sr, wf_consistency)` folds the three into a level plus its reasons, on a fixed point scale: PBO above 0.5 scores two points and above 0.2 scores one; a deflated Sharpe below 0.5 scores two and below 0.9 scores one; walk-forward consistency below 0.5 scores one. Three points or more is `High`, one or two is `Medium`, zero is `Low`. A `NaN` PBO is **not assessed** — it gets its own reason row rather than a vote toward `Low`. The Studio has this wired end to end, and it is the fastest way to see the numbers move. Its sweep computes the deflated Sharpe over every trial's per-period Sharpe, and PBO over the full transposed returns matrix at the canonical 16 splits — computed off the UI thread, with the PBO left `NaN` unless there are at least two entries and at least 16 observations. The results pane then shows the single-run half (Sharpe, observations, skew, kurtosis, PSR) and the set half (deflated Sharpe, PBO, overfit risk, walk-forward consistency when a walk-forward ran) with the verdict's reasons as notes. One habit is worth more than any of these numbers: read `n_candidates` first. It is the first number to check when a result looks too good. Next: [Monte Carlo](/docs/trader/analytics/monte-carlo) and [overfitting](/docs/trader/analytics/overfitting) for the reference pages, [sweeps](/docs/trader/backtesting/sweeps) and [walk-forward](/docs/trader/backtesting/walk-forward) for producing the trial set honestly, and [results](/docs/trader/gui/studio/results) for the pane that renders it. # A multi-timeframe strategy (/docs/trader/tutorials/multi-timeframe-strategy) A multi-timeframe strategy trades one series while consulting a coarser one — enter on the 1-minute bar only when the 4-hour trend agrees. Nothing in Vike Trader asks you to load two series to do that. `crates/vike-backtest/src/timeframe.rs` synthesises the coarse stream from the fine one, and its header states the property the whole tutorial rests on: the module is **look-ahead-safe by construction**, because a coarse bar only becomes visible once its window has fully elapsed, and the engine enforces that visibility. ## The vocabulary [#the-vocabulary] `parse_timeframe` converts a string to milliseconds by delegating to the single interval vocabulary in `vike_model::time::interval_ms`, so a `"30s"` series that loads from the history store is a valid backtest timeframe alongside `"1m"`, `"4h"` and `"1d"`; anything else comes back as an error naming what was expected. `parse_timeframe_accepts_seconds_and_rejects_garbage` pins both halves, including the rejections (`"1x"`, `"m"`). `resample(bars, target_ms)` aggregates fine bars into coarse ones **aligned to epoch windows** — one coarse bar per window, chronological, and the final window may be partial. Window membership is a floor-mod of each bar's timestamp, so a `"4h"` series starts at 00:00, 04:00, 08:00 UTC regardless of where your slice begins. ## The two reads, and why they differ [#the-two-reads-and-why-they-differ] The engine builds the aggregates for you. `EngineParams` carries a `timeframes: Vec` field, and `crates/vike-backtest/src/engine.rs` resamples each registered timeframe **per symbol** at construction. A strategy then asks the broker: ```rust // One resample per (symbol, timeframe), done once at engine construction. let params = EngineParams { timeframes: vec!["1h".into(), "4h".into()], ..Default::default() }; // Inside on_bar, per base bar: let closed: &[Bar] = broker.bars_for(&self.symbol, "4h"); // complete windows only let forming: Option = broker.forming_for(&self.symbol, "4h"); ``` `BarSeriesBuffer::bars_for` documents the rule both spellings implement: the coarse list up to — **but not including** — the window that contains `now`, delivered on complete, no look-ahead. `SimBroker::bars_for` is that read per symbol, with the same arithmetic. That exclusion is the no-look-ahead rule: on a base bar stamped 09:15 you can see the 04:00–08:00 coarse bar and never the 08:00 one, because it has not closed. `forming_for` is the explicit opposite: the still-forming coarse bar up to `now`, or `None` when no base bar has started the current window. Asking for a timeframe that was never registered panics with `timeframe … not registered`, so a typo in a string is loud rather than silently empty. The trait every strategy is generic over exposes `bars(symbol)` — the **base** series — and nothing else. `bars_for` and `forming_for` are inherent methods on `SimBroker`, so a strategy that calls them is written `impl Strategy` and is bound to the simulator. A strategy meant to stay portable across brokers resamples for itself from `broker.bars(symbol)`, using the same `resample` function the engine uses — same arithmetic, same window alignment, one owner of the law. ## The shared buffer [#the-shared-buffer] `BarSeriesBuffer` (`crates/vike-backtest/src/bar_buffer.rs`) is that second path packaged. Construct it with the base bars and the timeframes you want; `bars_for(tf, now)` and `forming_for(tf, now)` answer exactly as the engine's per-symbol twins do, and `add_live_bar` appends a base bar and refreshes every registered timeframe's aggregate — the forward mode, for a series that is still growing. Its header records one Rust adaptation worth knowing before you read the code: the buffer **owns** the base-bar `Vec` and the engine reads through it, where the ported original shared a list by reference. One owner, same observable behaviour. One numeric detail is deliberate rather than incidental. `forming_for` folds the window's volume through `vike_model::py_sum`, the Neumaier-compensated sum, while high and low fold in order — the mirror of the [parity gate](/docs/trader/concepts/parity-gate)'s rule that a ported `sum()` site uses `py_sum` and an explicit accumulation loop stays a naive fold. Reproduce that if you write your own aggregation; a plain `.sum()` there is a bit-level divergence. ## What a profile cannot reach [#what-a-profile-cannot-reach] There is no `[engine]` key for this. `crates/vike-backtest/src/harness/profile.rs`'s `EngineCfg` declares no `timeframes` field, so a TOML [run profile](/docs/trader/backtesting/run-profiles) cannot register a coarse timeframe and the `backtest` bin cannot run a multi-timeframe strategy that depends on `bars_for`. Multi-timeframe work today is a Rust one: construct `EngineParams` yourself, or resample inside the strategy from `broker.bars(symbol)` — which is also the version that survives being mounted anywhere else. ## Verify it [#verify-it] Two checks are worth doing before you trust a signal built this way. 1. **Count the coarse bars against the window arithmetic.** Over a slice of `n` one-minute bars, a `"4h"` resample yields one bar per 240-minute epoch window the slice touches, with the last one partial. If your count disagrees, your slice boundaries are not where you think. 2. **Assert the exclusion.** At a base timestamp inside a coarse window, `bars_for` must not return that window's bar and `forming_for` must return it. A strategy that reads a coarse close before the window shut is the classic backtest that cannot be reproduced live, and this is the one line of test that catches it. Next: [timeframes](/docs/trader/backtesting/timeframes) for the reference page, [the portfolio engine](/docs/trader/backtesting/portfolio-engine) for how per-symbol state is laid out, and [tick replay](/docs/trader/backtesting/tick-replay) when the base stream is ticks rather than bars. # A VPIN study over a recorded tape (/docs/trader/tutorials/orderflow-vpin-study) `vike-orderflow` is a pure trade-aggregation core: no I/O, no rendering, no threading. You hand it `vike_model::TradeTick`s and it hands back order-flow primitives. This tutorial takes a recorded tape off the history store and builds three studies from it — CVD, a footprint, and VPIN — in the order the crate itself puts them. ## Get the tape [#get-the-tape] `HistStore::scan_trades(venue, symbol, range)` returns the executed trades for that series in the range, timestamp-ascending. That is the whole input. Recording one is [record a feed, then replay it](/docs/trader/tutorials/record-and-replay); note there that Binance serves a trade tape and a conflating depth lane but no quotes and no lossless book, which matters for the mid-price variant below. ## The one decision everything inherits [#the-one-decision-everything-inherits] `crates/vike-orderflow/src/classify.rs` calls itself "the one load-bearing semantic of the whole crate", and it is one line of logic: `is_buyer_maker` is the Binance aggregate-trades convention — the buyer was the resting maker, sitting on a bid, so the **aggressor was the seller**. `is_buyer_maker == false` classifies Buy (an ask was lifted); `true` classifies Sell (a bid was hit). `signed(t)` then routes the trade's **full** size to the aggressor's side, and `delta = buy_vol − sell_vol` everywhere downstream. If your tape's flag means something else, every number on this page is sign-flipped. Check it once, at the source. ## Cumulative volume delta [#cumulative-volume-delta] The smallest study, and the one to sanity-check the tape with. `CvdAccumulator::push(&trade)` folds one trade and `value()` reads the running sum; `CvdAccumulator::from_trades(&trades)` is the batch twin over a slice. Non-positive sizes are skipped rather than folded, in this and every other builder. Fold the whole slice both ways. They must agree bit for bit — that is the crate's own gate discipline, described in its header as streaming-versus-batch parity compared on `f64::to_bits`, where the batch path is a genuinely separate slice computation rather than `push` in disguise. If your own aggregation disagrees with `from_trades`, the bug is yours. ## A footprint [#a-footprint] `FootprintBuilder::new(tick_size)` asserts a positive tick size, `push` accumulates each trade into its price bucket, and `close_bar()` returns a `FootprintBar` — a bar index plus ascending `PriceBin` cells, each carrying `buy_vol` and `sell_vol` at one price. `FootprintBuilder::from_bars(&bars, tick_size)` is the batch twin over trades already grouped per bar. To group them, use the bar builders in the same crate: `TickBarBuilder`, `VolumeBarBuilder` and `DollarBarBuilder` each emit an `OrderflowBar` carrying `o/h/l/c`, `buy_vol`, `sell_vol`, `delta`, `trade_count` and first/last timestamps. The volume and dollar builders **split a trade proportionally across a threshold boundary** — by volume and by cumulative price·size respectively — which is the same convention VPIN uses below, so the two agree about where a bucket ends. The bucketing itself is owned by `profile.rs`, not re-derived: the footprint reuses that module's `PriceBin`, its price-to-bucket rounding and its grid-to-bins transform. The same module builds a `VolumeProfile` with its point of control and a 70% value area, if what you want is volume-at-price rather than per-bar cells. ## VPIN, twice [#vpin-twice] `crates/vike-orderflow/src/vpin.rs` ships two estimators, and the difference is what they need from the tape. **`Vpin`** chops the stream into fixed-volume buckets of `bucket_volume`; a trade that overflows the open bucket spills its remainder into the next. Each completed bucket scores an order-flow toxicity imbalance `|Vbuy − Vsell| / Vbucket` in `[0, 1]`, and VPIN is the rolling mean of the last `window` completed buckets — `DEFAULT_VPIN_WINDOW` is 50 — maintained as an O(1) rolling sum, never a window re-scan on the hot path. ```rust let mut v = Vpin::new(bucket_volume); // aggressor-classified, window = 50 for t in &trades { for imbalance in v.push(t, None) { // one value per bucket this trade COMPLETED // ... a big trade can complete several } } let toxicity = v.committed(); // None until the first bucket completes ``` `committed()` reads completed buckets only. `forming_bucket()` and `interim()` are pure reads that fold the open bucket's partial imbalance in as the newest window sample — evicting the oldest when the window is full — without mutating state, which is the convention the bar builders use too. For a feed with no real aggressor flag, construct with `has_aggressor = false` and pass the prevailing mid to `push`: `price >= mid` classifies Buy, otherwise Sell. A missing mid falls back to the aggressor flag, so **no trade is ever dropped**. **`BvcVpin`** is the canonical Easley, López de Prado and O'Hara (2012) VPIN, and it needs only price and volume. A bucket's directional split comes from its price change rather than trade-level tags: `buy_frac = Φ((P_i − P_{i−1}) / σ_ΔP)`, with `Φ` the standard-normal CDF over `libm`'s erf and `σ_ΔP` the population standard deviation of bucket price changes over the last `window` buckets. The bucket imbalance is then `|2·buy_frac − 1|`. `directional_bias()` gives the signed version — above zero is buy-toxic, below is sell-toxic — so a caller can route the magnitude onto a per-side gate. `value()` is `None` until `window` bucket **imbalances** exist, and that takes `window + 1` bucket closes: the first close only sets the `P_{i−1}` reference, because a price *change* needs two. A study that stops early and reads `None` has not found a degenerate tape — it has not finished warming up. Its degenerate guards are worth knowing before you tune a bucket size. A non-positive or non-finite `bucket_volume` and a `window` below 1 **panic at construction**. When `σ_ΔP` is undefined — during warm-up, or on a flat tape — the estimator falls back per bucket to the tick rule, which classifies a bucket wholly by the sign of `ΔP`, so a flat tape yields `0.0` rather than a `NaN` from `Φ(0/0)` and a one-way tape yields `1.0`. `VolumeClassifier::TickRule` selects that behaviour outright. The module documents a default for the window and none for the bucket size, and that is honest rather than an omission: bucket volume is a property of the instrument's own tape, and the guards above tell you what the extremes do. Next: [VPIN](/docs/trader/orderflow/vpin), [CVD](/docs/trader/orderflow/cvd) and [footprint](/docs/trader/orderflow/footprint) for the reference pages, [buy/sell classification](/docs/trader/orderflow/classification) for the semantic in full, and [in the GUI](/docs/trader/orderflow/in-the-gui) for where each study renders. # Pairs stat-arb with a cost gate (/docs/trader/tutorials/pairs-stat-arb) `pairs_zscore` is the shipped statistical-arbitrage template: a rolling z-score of the spread `A − β·B`, entered when the spread is stretched and exited when it reverts. What makes it worth running is the gate in front of the entry — it refuses a trade whose expected convergence does not exceed a full round trip INCLUDING carry. `crates/vike-strategy/src/pairs.rs`' own module doc says so. Published evidence, measured on ten Binance perpetuals at 2 bp per crossing — an order of magnitude cheaper than a retail taker pays — decomposes a round trip as gross `+0.15%`, trading cost `−0.12%`, funding `−0.18%`, net `−0.15%`. The signal is real and it is about 15 bp. The purpose of the template is to reproduce that verdict on your own data, with your own fees and venues, through your own overfitting gates. Expect a negative; that is the successful outcome. ## 1. A two-symbol profile [#1-a-two-symbol-profile] The strategy needs both legs' bars, so the profile's data slice names two symbols: ```toml name = "pairs_zscore — BTCUSDT / ETHUSDT 1h" [data] kind = "bar" interval = "1h" venue = "binance" symbols = ["BTCUSDT", "ETHUSDT"] from = "2025-01-01T00" to = "2026-01-01T00" [engine] cash = 10000.0 fee_rate = 0.0005 [strategy] name = "pairs_zscore" [strategy.params] symbol_a = "BTCUSDT" symbol_b = "ETHUSDT" period = 20 entry_z = 2.0 exit_z = 0.5 notional = 1000.0 ``` Every key this table does not set keeps the value in `PairsZScore::default` — `beta` 1.0, `taker_fee` 0.0005, `half_spread_bps` 1.0, `hold_intervals` 3.0, no funding assumption and no half-life gate. Sections 5 and 6 are about what those defaults assert. Both slice forms reach the same code path — `DataCfg::resolved_series` expands the frozen `venue` + `symbols` pair exactly as it resolves a cross-venue `[[data.series]]` array — so a pair whose legs live on different venues is the same profile with the longer spelling. `PARAM_KEYS`' `pairs_zscore` row declares every key the reader takes and its TOML type: `symbol_a`, `symbol_b`, `period`, `entry_z`, `exit_z`, `beta`, `notional`, `taker_fee`, `half_spread_bps`, `hold_intervals`, `funding_a`, `funding_b`, `max_half_life`. `period` is a COUNT read through `as_f64` and then rounded, so it is declared `Number` and `period = 2.6` is legal input that resolves to `3` — a coercion no type check can see. ## 2. Fetch both legs [#2-fetch-both-legs] Public klines, no credentials, no venue account: ```sh backtest --fetch binance:BTCUSDT:1h --from 2025-01-01T00 --to 2026-01-01T00 ``` ```text 8760 bars returned, covering 1735689600000..=1767222000000; 8760 rows written ``` The second leg is the same command with the other symbol. A pair needs both spans present before the profile runs — a leg that is short does not error, it simply never completes a step. ```sh backtest --fetch binance:ETHUSDT:1h --from 2025-01-01T00 --to 2026-01-01T00 ``` ```text 8760 bars returned, covering 1735689600000..=1767222000000; 8760 rows written ``` ## 3. The first run traded nothing, and the profile never said so [#3-the-first-run-traded-nothing-and-the-profile-never-said-so] The first attempt at this tutorial guessed its parameter names instead of reading them off the registry. That produced a complete run and no orders at all: ```sh backtest --profile tut_pairs.toml ``` ```text name: pairs_zscore — BTCUSDT / ETHUSDT 1h final_equity: 10000.00 n_trades: 0 no trades executed - probable cause(s), most likely first: [1] No order-blocking cause was detected. Over 8760 bar(s)/tick(s) nothing was denied, deferred, or gated and no trade closed, so no gate explains the flat result. The strategy most likely never submitted an order - check its entry condition and that it reads the SAME symbol the data is tagged with (bar-mode `snap_to_properties` re-tags bars as `SYMBOL.VENUE`, which a strategy routing on the bare symbol will miss). ``` The diagnosis is right, and it is the only signal you get. `PairsZScore::from_params` is a READER: it ignores a key it does not know and falls back to a default for a key that is absent. Both symbols default to the EMPTY string, and that constructor's own doc states the consequence — `symbol_a` and `symbol_b` are required in practice, because with either empty the strategy never routes an order. Nothing upstream catches it either. `StrategyCfg` types `params` as an arbitrary `toml::Value`, so `deny_unknown_fields` never looks inside it, and `vike_strategy::unknown_params` — the check that would have named the misspelling — runs in `vike-tradehub` at mount time, not in a backtest. A backtest's whole defence here is `ZeroTradeReport::analyze`, which is why a flat run prints a ranked diagnosis instead of an all-zero table. The key names are not a guess anywhere. `crates/vike-ops/src/docs_data.rs`'s `template_record` exports one record per `PORTABLE_STRATEGIES` name carrying that name's `PARAM_KEYS` row and its `LIVE_CAPABLE` verdict. The six keys the profile above sets come from there. ## 4. The corrected run [#4-the-corrected-run] Same tape, same span, the keys spelled as the registry exports them: ```sh backtest --profile tut_pairs.toml ``` ```text name: pairs_zscore — BTCUSDT / ETHUSDT 1h final_equity: 9984.62 total_return: -0.1538% n_trades: 90 win_rate: 57.7778% sharpe: -0.0851 max_drawdown: 2.0435% per_symbol_pnl: BTCUSDT: 21.90 ETHUSDT: 9.09 ``` **It lost money.** A year of hourly bars on the two largest crypto instruments, a cost-gated spread, and the account finished 15.38 down on 10,000. Ninety closed trades is forty-five spread round trips: `flatten` exits both legs together, so a completed trip books one closed trade on each symbol. Out of a year of hourly z-scores, forty-five entries is the cost floor rejecting most band breaks rather than a strategy sitting idle. Now the line worth stopping on. **Both legs are positive** — `+21.90` on BTCUSDT and `+9.09` on ETHUSDT, about `+31` between them — while the account is down. That is neither a contradiction nor a reporting bug. `per_symbol_pnl` is built from each symbol's running `realized`, which accumulates `FillOutcome::realized_pnl` — documented as GROSS price PnL on the closing quantity. The fee is carried separately, and `SimBroker::apply_fill` takes it straight out of cash with `cash -= fee`. Fees never enter the per-symbol column. The roughly 46 dollars between the two numbers is what the crossings cost. So the run is a picture of a strategy that was right more often than not, on both legs, and paid more for the privilege than being right was worth. That is the template's thesis reproduced on one pair. **What it does not say.** It is not evidence that pairs trading loses money: one pair, one venue, one year, one parameter set, one fee rate. A Sharpe of `-0.0851` is a hair below zero, so this run is statistically indistinguishable from flat, and reading a verdict of either sign into it would be the overfitting the template exists to expose. It is also not a market-neutral result. `beta` stayed at its default `1.0`, which sizes the hedge in equal UNITS rather than equal dollars, so one unit of the BTC leg was hedged with one unit of ETH — a few percent of the first leg's dollar value. Fit your own beta before reading the sign. And it does not validate the cost model, because the cost model here is an assumption: `half_spread_bps` was 1.0 and `hold_intervals` 3.0 because nothing set them otherwise. A wider real book refuses more entries; a cheaper one lets more through. Section 6 is the knob you owe a number to. ## 5. The two things this template fixes [#5-the-two-things-this-template-fixes] **Beta SIZES the hedge leg.** The structure is adapted from a published implementation that fits `beta` by OLS, uses it only to define the residual, and then splits the legs 50/50 by dollar value — beta-blind, so the position is not beta-neutral despite beta being computed. Here `beta_neutral_qtys(notional, price_a, price_b, beta)` returns `(notional/price_a, β·qty_a)`, and refuses outright (`None`) on a non-finite or non-positive beta rather than inverting a leg behind your back. With `beta = 1` that is equal UNITS, not equal dollars — section 4 is what that default looks like in a report. **The barriers are symmetric and cost-gated.** The prior art's `+1% / −5%` global barriers are EV-neutral by construction on a driftless process (`5/6·1% − 1/6·5% = 0` exactly) while looking excellent over a short sample. Here `should_enter` requires the z-score outside `entry_z` AND the expected convergence `edge` to exceed the total cost, and `should_exit` fires on reversion into `exit_z` or on a sign flip relative to the z the position was opened at. The edge is deliberately `(|z| − exit_z)·sd`, not the full `|z|·sd` — you exit at the exit band, not at the mean. ## 6. Where the cost number comes from [#6-where-the-cost-number-comes-from] `vike_model::spread_quote` prices what a multi-leg spread actually costs to trade rather than how its mid looks. Its sign rule is the load-bearing part: BUYING a spread means lifting the ask of every positive-ratio leg and hitting the bid of every negative-ratio leg. A naive `bid_A − bid_B` / `ask_A − ask_B` construction takes the same side on every leg, which understates crossing cost and produces a CROSSED quote whenever the short leg is wider than the long one. When the cost is unknowable — a crossed or missing book — `should_enter` is `false`. An unpriceable trade is not a free one. Two knobs decide how honest the gate is: * **`half_spread_bps`** is the assumed book width, since bars carry no L2. It is not optional in spirit: leaving it at zero asserts a frictionless book, which is the assumption this strategy exists to test. * **`hold_intervals`** is how many funding intervals the carry half of the floor is charged for. It is always used on an entry check — nothing a bar carries replaces it. `funding_a` / `funding_b` are the rates it multiplies, until a bar for that leg carries a funding rate of its own; that rate is then remembered and used for every later entry check on that leg, funding-less bars included. `max_half_life` is an opt-in regime gate on top: refuse entry when the spread's OU half-life exceeds that many bars, or is NaN — not mean-reverting at all is a stand-down, not a pass. `<= 0.0` disables it. ## 7. What else the report carries [#7-what-else-the-report-carries] The z-score kernel is bit-identical to `vike_indicators::pairs::spread_zscore` — not merely the same formula but the same running-sum recurrence, asserted bitwise by `inline_zscore_matches_the_indicator_seam` — so a spread you plot on a chart and a spread the strategy trades are the same number over the same window. `zero_trade` is present ONLY when the run closed no trades AND its equity never moved, which is why section 3's output carries a diagnosis and section 4's carries a metrics table. Once trades exist, the reading is yours to do. `vike_strategy::LIVE_CAPABLE` refuses the name with its reason: it is TWO-LEG and needs `symbol_a` + `symbol_b` as declared mount legs admitted into the venue engine's `extra_symbols`. A mount declares no legs today, so the second leg's bars would never arrive and its orders would be dropped at `ExecutionEngine::accepts_symbol`. `vike-tradehub` refuses it at profile LOAD rather than mounting a strategy that would silently never submit. Everything above is a backtest of a strategy you cannot currently mount, and the registry export says so in the same record that names its keys. ## Where next [#where-next] * [The pairs-zscore template](/docs/trader/strategies/templates/pairs-zscore) and [pairs indicators](/docs/trader/indicators/pairs). * [Cost model](/docs/trader/backtesting/cost-model) and [Objectives](/docs/trader/backtesting/objectives). * [Sizing and zero-trade diagnosis](/docs/trader/analytics/sizing-and-zero-trade) — the ranked causes section 3 met, in full. * [Funding carry across two venues](/docs/trader/tutorials/funding-carry-two-venues) — the other cost-dominated template. * [The strategy registry](/docs/trader/strategies/the-registry) — why a name can resolve and still be refused. # Record a feed, then replay it (/docs/trader/tutorials/record-and-replay) If you want to backtest a market you have actually watched, you have to record it first. `crates/vike-recorder/src/lib.rs` states the reason plainly: a customer has no production box and no warehouse, the recorders behind the data product share no code with the terminal, so the terminal has to accumulate the tape itself. It is its own binary on purpose. Recording needs venue feeds, and those drag a signer, a WebSocket stack and TLS into whatever links them — keeping them out of the backtest server means a panic in feed parsing cannot take serving down. Not the desktop app, because a tape that exists only while a window is open has a hole wherever you closed it. Not the trading daemon, because recording must run whether or not you are trading, and a daemon restart must not punch a hole in the tape. ## The profile [#the-profile] One reviewable TOML names the store and what to record. From the shipped `crates/vike-recorder/recorder.example.toml`: ```toml store = "market_data/hist" [[subscribe]] venue = "polymarket" family = "btc-updown-5m" backfill = "off" [maintenance] interval_secs = 300 min_parts = 4 ``` A `family` is general across venues, not a Polymarket-shaped concept: Polymarket resolves one to live tokens over its Gamma API because its token ids rotate every five minutes, while a static venue resolves the same key as a glob filter over its instrument list. Explicit `symbols` stay per-symbol with no grouping — grouping only pays across a wide subscription. A subscription names one or the other, never both. The directory name `hist` is not a free choice: it is the name every binary's own default resolves to (`vike_model::state_path`'s `HIST_SUBDIR` under `PROJECT_DATA_DIR`). A store called anything else is one the code cannot name, and a reader run without `--store` opens `/market_data/hist`, finds nothing, and reports zero rows rather than failing. And the **relative** path resolves against the process working directory — right for a daemon whose unit sets `WorkingDirectory`, wrong for every command you run by hand. Measured: a hand-run single-tick run from a source checkout wrote its tape into the checkout while the project's own `market_data/` stayed empty. Every struct in this file is `deny_unknown_fields`, so a misspelled key is refused **by name**, with the line number and the keys that would have been accepted. That is a refusal rather than a nicety: `webhook` for `webhooks` left an operator believing a pager was armed, a misspelled `retention_days` kept the tape forever after they asked for thirty days, and `backfil` silently fell back to venue-REST backfill — which on Polymarket cannot restore the book at all. ## Building and dry-running it [#building-and-dry-running-it] Venue support is a Cargo feature, because a bridge is heavy: a build recording only static-symbol venues should not link an EIP-712 signer. A venue this build has no feature for is a **startup error naming the feature to rebuild with**, never a silent no-record. Build `vike-recorder` with `--features polymarket` (or `binance`), then run it with `--profile ` and `--once`. `--once` runs a single tick and exits: it proves the profile parses, the store opens and each family resolves to live symbols, without leaving a daemon running. **Read its exit code, not its log** — `0` only if every feed ended that tick with a live subscription, `4` otherwise, naming each feed that would record nothing. It exited `0` on a total resolve failure until 2026-08-08. One capability note before you plan a study around it: Binance's declared caps are bars, trades and depth — **`quotes: false`, `book: false`** — so a Binance recording is the trade tape plus the conflating depth lane under its own `kind=depth` series. A lossless Binance L2 book is not obtainable in this workspace by any means, and `depth` is deliberately not a rename of one. ## Stopping it without losing the tape [#stopping-it-without-losing-the-tape] One stop flag, many triggers: a control word on stdin (`quit` / `shutdown` / `stop` / `exit`), and **SIGTERM and SIGINT** through `vike_ops::stop`'s `install_handlers`, so a service-manager stop, a bare `kill` and Ctrl-C all reach the same teardown. Until that handler existed SIGTERM reached none of it — and the flush is where the tape was lost, on every restart of every box. On Windows there is no POSIX signal, so the same `install_handlers` call bridges the console control events instead — Ctrl-C, Ctrl-Break and a window close all raise the same flag, and a recorder running in a console stops as gracefully there as a service stop does on Linux. The caveat is that a console event needs a console: a recorder started detached has neither one nor a stdin anybody can type into, and the stop-file trigger `vike-ops` also offers is armed by `vike-tradehub` alone — so on that path `taskkill` is still a kill, not a stop. Four words, not three: `crates/vike-datahub/src/recorder.rs`'s `control_loop` matches `exit` alongside the other three, while that file's module doc and its reply to an unrecognised word both list only three. Follow the code — `exit` works. Teardown order is load-bearing: unsubscribe every feed **first**, then shut the sink down. The other order leaves feed threads pushing rows into a channel whose writer is gone, and those rows are counted as dropped with no reporter left to announce them. The whole sequence is budgeted end to end, because a service manager follows SIGTERM with SIGKILL — a feed-stop budget plus a bounded flush tail, summing to what the shipped unit's `TimeoutStopSec` is checked against, ordered flush-first so a hard cap abandons a compaction pass and never the rows. `[maintenance]` is optional and **its absence means defaults, not off**: the recorder commits once per buffer flush, and a busy series was measured writing 23 parts in 150 seconds for one family's book at about 57 KB each — roughly 13,000 files a day if nothing merges them, and every later scan pays for all of them. ## What lands, and how [#what-lands-and-how] Rows arrive through `RecorderSink` (`crates/vike-data/src/live_rec.rs`), which never blocks a feed thread — it only ever `try_send`s a row and bumps a dropped counter on a full or disconnected channel — and does every store write on one dedicated writer thread that owns the store handle. Book recording is the **raw** `book_update` lane; the folded-state `book` verb is a documented no-op, because a folded `L2Book` is not serialisable and carries no delta or replay information. Buffers flush on row count, on age, on a UTC-date rollover, or at shutdown, so a buffer never straddles a day and never outlives the process. The silence watchdog is a pair: one module decides which subscribed series stopped receiving rows, another decides who is told. Both are always on — a silent series is logged and alerted — while **exiting** on silence is opt-in, because a venue can be legitimately quiet. ## Replaying it [#replaying-it] Point a tick [run profile](/docs/trader/backtesting/run-profiles) at that store root, passing `--store /market_data/hist` to the `backtest` bin. That flag falls back to `$VIKE_HIST_STORE`, then to the `/market_data/hist` of a project `$VIKE_SETTINGS_DIR` declares outright, then to `/market_data/hist` while the checkout that built the binary still exists, then to the project the working directory sits in, and finally to a per-user data directory — the same precedence the collector bins resolve through, and the root and the rung that chose it are logged. The replay merges each symbol's quotes, trades and book events into one timestamp-ordered stream with the tie-break **Book, then Quote, then Trade** — book first because a live derived quote follows the book application that produced it — and a symbol with zero rows of all three kinds is skipped with a warning rather than failing the run, so an all-sparse window degrades to an empty but valid result. The streaming loader is pinned bit-identical to the bounded one by `streaming_equals_bounded_bit_eq`, so which of the two you reach for changes memory shape and never numbers. Next: [record a live feed](/docs/trader/guides/record-a-feed) and [replay a recorded session](/docs/trader/guides/replay-a-session) for the task versions, [tick replay](/docs/trader/backtesting/tick-replay) for the loader, and [tick replay with a queue model](/docs/trader/tutorials/tick-replay-with-queue-model) for what having the book on disk buys you. # A Rhai strategy from scratch (/docs/trader/tutorials/rhai-strategy-from-scratch) A Rhai script is not a second-class strategy path. `vike_script::RhaiStrategy` implements `vike_model::Strategy` — the same seam every compiled Rust strategy is driven through — so the backtest simulator and the live core mount it with no special case. What differs is the host surface: a script cannot see a broker, only a handful of registered functions. ## The shape of a script [#the-shape-of-a-script] Three hooks, each a zero-argument `fn`, all optional. `RhaiStrategy::compile_with_indicators` detects which of `on_start` / `on_bar` / `on_stop` the AST defines and calls only those. ```rust // Top level: runs EXACTLY ONCE, at compile. let len = param("len", 20.0); let qty = param("qty", 1.0); fn on_bar() { let m = sma(len.to_int()); // read first, every bar if m.is_nan() { return; } // warm-up let target = if close() > m { qty } else { 0.0 }; let delta = target - position(); if delta > 1e-9 { market( 1, delta); } if delta < -1e-9 { market(-1, -delta); } } ``` **The top level runs once and never again.** `compile` executes it through `Engine::run_ast_with_scope` and `run_hook` then calls with `CallFnOptions::default().eval_ast(false)`, because rhai's default re-evaluates an AST's top level on every call — which would make a bare top-level `buy(1.0);` resubmit an order on every hook invocation. Anything that one-time run recorded is discarded: the intents, and the indicator caches too, so the first real `on_bar` is the first bar any indicator has ever seen. **A script holds no cross-bar mutable state.** `run_hook` clones the persisted scope for each call, so a hook's own `let` locals die with the clone; a top-level `const` survives because it was in the scope before the clone was taken. State lives host-side — the broker's `position()` and the indicator caches in `ScriptCtx`. ## What you can call [#what-you-can-call] `engine::HOST_FN_NAMES` is the whole non-indicator surface: the reads `close` / `open` / `high` / `low` / `volume` / `position` / `price` / `equity` / `index` / `now`, the order verbs `buy` / `sell` / `limit` / `market`, and `param`. Each verb pushes an `Intent` into `ScriptCtx::intents` through a cloned `Arc` — `buy(qty)` and `sell(qty)` are `market(1, qty)` and `market(-1, qty)`; `limit(side, qty, price)` rests one — and `strategy.rs` drains the recorded intents onto the real broker at the end of the bar. `param(name, default)` is the sweepable knob. First-seen wins in the `(name, default)` list discovery reports — the call itself still returns its own default — the injected override replaces the default when a profile supplies one, and the default argument is a `rhai::Dynamic` on purpose: registered as `f64` it accepted `param("fast", 5.0)` and rejected `param("fast", 5)`, so a knob worked or did not depending on a decimal point. Indicators are bound from `vike_indicators::registry()`, not hand-listed: `RHAI_INDICATORS` is the registry minus everything `unbound_reason` rejects, and `register_indicators` filters on the same predicate, so the advertised set and the bound set cannot drift. Each bound name gets one form per argument count from zero (all registry defaults) up to its own parameter count. `MAX_INDICATOR_ARITY` is the ceiling on that ladder, and an indicator with more parameters than it is not bound at a lower arity — it is not bound at all. See the fourth refusal below. An indicator advances only when the script calls it. A call behind an `if` silently skips the bars the branch did not take, and the number it then returns is no longer an average of the last N — a wrong value, never an error. Read every indicator on the first lines of `on_bar`, before any branch or early return. Five kinds of registry entry are deliberately not bound, each because binding them would return a silently wrong number: a name rhai reserves (`var` is a reserved word, so `var(20)` fails at parse time), a name that would shadow a host function, the `batch_only` three that read future bars (`ichimoku` / `zigzag` / `williams_fractal`), an indicator declaring more parameters than `MAX_INDICATOR_ARITY`, and a multi-output indicator whose line 0 is not its namesake. The fourth is the one the argument-count ladder above would otherwise hide: the forms stop at the ceiling, so binding such an indicator would pin every parameter past it to the registry default — knobs that look present and are not. Today that is `kst`, with nine; it is absent rather than crippled, and Rust is where you construct it. The fifth is about which line you get: `bollinger(20)` would hand back the UPPER band to someone who read it as the middle, so the bare name is refused and `bollinger_mid()` is the spelling — every line of every multi-output indicator has its own accessor. `unbound_reason` answers why any particular name is missing. `vike-cli indicators` prints the callable set off the same derived list the host binds from, including your own `user_data/indicators/*.rhai` files in their own block. ## What the engine will not let you do [#what-the-engine-will-not-let-you-do] `build_engine` opts into bounded limits (of these, a plain `rhai::Engine::new()` bounds only call depth, and that already at 64 in a release build), applied per hook invocation rather than cumulatively: 2,000,000 operations, 64 call levels, 8 KB strings, and 4096 elements for an array or a map. `set_module_resolver` installs a `DummyModuleResolver`, so every `import` is refused — a default engine roots a `FileModuleResolver` at the process working directory, which for the daemon is ``, the operator's settings tree and credential store included. `print` and `debug` are redirected onto the `tracing` facade, because the embedding binary's stdout is a protocol. Runtime errors fail safe: a hook that errors emits zero orders that bar and any intents it recorded before the error are discarded. After ten CONSECUTIVE errors the strategy self-disables; a successful call resets the counter. ## Write the indicator the strategy needs [#write-the-indicator-the-strategy-needs] The rest of this page is one real strategy, written from nothing and run to a number. It wants two values per bar: a breakout level, which needs a memory of earlier bars, and a measure of how wide the current bar is, which needs none. The first ships with `vike-cli init` as `user_data/indicators/donchian_high.rhai`. The second we write — the smallest shape the indicator seam accepts: ```rust // range_pct — this bar's high-low range as a percentage of its close. // // The smallest useful shape: no window, no state, one number per bar. fn init() { #{} } fn on_bar(bar) { if bar.close == 0.0 { return (); } (bar.high - bar.low) / bar.close * 100.0 } ``` Only `on_bar` is required. `crates/vike-script/src/indicator.rs` declares `INIT`, `WARMUP`, `OUTPUTS` and `OVERLAY` as optional, and an absent `init` yields exactly the empty map this file returns — it is written out here because it is the hook you extend the moment the indicator needs to remember anything. Returning `()` is the seam's NaN. A bar whose close is zero has no percentage, so the indicator says so rather than dividing. ## The strategy that calls it [#the-strategy-that-calls-it] ```rust // quiet_breakout — buy a breakout, but only out of a quiet bar. // // Uses the shipped donchian_high user indicator for the breakout level and range_pct (written for // this tutorial) to insist the bar that breaks out is not itself a wide, already-moved bar. let lookback = param("lookback", 48.0); let calm = param("calm", 1.0); let qty = param("qty", 0.05); fn on_bar() { let hi = donchian_high(lookback.to_int()); let rng = range_pct(); if hi.is_nan() || rng.is_nan() { return; } if close() > hi && rng < calm && position() <= 0.0 { market(1, qty); } if close() < hi * 0.97 && position() > 0.0 { market(-1, position()); } } ``` Both indicator reads sit on the first two lines of `on_bar`, unconditionally, ahead of every branch — the rule from the callout above. The NaN check comes after both reads, never instead of one. `lookback` is declared as `48.0` and converted at the call site, because the indicator's own knob is a count of bars. ## Validate the knobs before you run [#validate-the-knobs-before-you-run] ```sh vike-cli backtest --list-params --script quiet_breakout.rhai ``` ```text lookback = 48 calm = 1 qty = 0.05 ``` `--list-params` is offline. `crates/vike-cli/src/cmd/backtest.rs`'s `execute` reads the file, runs `vike_script::discover_params` and stops — no profile, no server. Compiling IS validation here, so a syntax error, or a TOP-LEVEL call to a function that is not bound, is the answer you get instead of a list — rhai resolves a name when the line RUNS, so a call inside `on_bar` still passes. An agent gets the same answer from the read-only `validate_strategy` MCP tool. The defaults print as rhai renders them, which is why `48.0` reads back as `48` and `1.0` as `1`. The names and their order are the script's own, in declaration order. `vike-cli indicators` is the companion check: it prints the callable set, with your own `user_data/indicators` files in their own block. ## The first run: zero trades [#the-first-run-zero-trades] Point a run profile at the script exactly as the [RSI tutorial](/docs/trader/tutorials/rsi-mean-reversion-backtest) does, then run it: ```sh vike-cli backtest --profile tut_breakout.toml ``` ```text { "final_equity": 10000.0, "funding_paid": 0.0, "max_drawdown": 0.0, "n_trades": 0, "name": "quiet_breakout — BTCUSDT 1h", "per_symbol_pnl": [ [ "BTCUSDT", 0.0 ] ], "profit_factor": 0.0, "sharpe": 0.0, "total_return": 0.0, "win_rate": 0.0, "zero_trade": { "causes": [ { "code": "no-orders", "detail": "Over 8760 bar(s)/tick(s) nothing was denied, deferred, or gated and no trade closed, so no gate explains the flat result. The strategy most likely never submitted an order - check its entry condition and that it reads the SAME symbol the data is tagged with (bar-mode `snap_to_properties` re-tags bars as `SYMBOL.VENUE`, which a strategy routing on the bare symbol will miss).", "headline": "No order-blocking cause was detected." } ] } } ...(diagnostic continues; the full JSON is in the run's report.json) ``` Nothing traded over 8760 hourly bars — a year of BTCUSDT. The `zero_trade` object is not decoration. `crates/vike-analytics/src/report.rs`'s `BacktestReport::zero_trade` is `Some` only when a run closed no trades AND its equity never moved, and when it is present the human `Display` table stops after `final_equity` and `n_trades: 0`, printing the diagnosis in place of the metric rows below it — the JSON above, which is all `vike-cli backtest` prints, carries both. `crates/vike-analytics/src/zero_trade.rs`'s `rank_causes` ranks the things that block orders: gate denials, the stale-price wait, a closed session, a warm-up shortfall, an empty data slice. Here none of them fired, so it emitted the catch-all `"no-orders"` cause. Read it for what it is — the analyzer diagnoses gates, not logic, and it is telling you that nothing stopped your orders because there were none to stop. One thing this run does not show you. The profile carries the strategy source in `[strategy.params].src` (the `--script` flag is what injects it — `crates/vike-cli/src/cmd/backtest.rs`'s `inject_script_src`), but the indicator FILES are never shipped. The service installs its own `/user_data/indicators/*.rhai` at startup — `crates/vike-datahub/src/datahub_cli.rs`'s `install_user_indicators` — and that set wins. The same script against two services with different indicator directories returns different numbers, and nothing in the response says so. ## The cause, and the corrected run [#the-cause-and-the-corrected-run] The first suspect was the range filter: `calm = 1.0` demands an hourly bar whose high-low range is under 1% of its close. Raising it to `3.0` and re-running produced the same zero-trade JSON. That is the useful half of a failed fix — the filter was not the gate. The gate was the comparison itself. The shipped `donchian_high` (`crates/vike-cli/src/cmd/init/content.rs`'s `DONCHIAN_HIGH_RHAI`) pushes the current bar's high into its window BEFORE taking the maximum: ```rust fn on_bar(bar) { this.highs.push(bar.high); if this.highs.len() > lookback { this.highs.remove(0); } if this.highs.len() < lookback { return (); } let hi = this.highs[0]; for h in this.highs { if h > hi { hi = h; } } hi } ``` So the value returned on a given bar already includes that bar's own high. A close can never exceed the high of its own bar, which makes `close() > donchian_high(n)` false on every bar of every tape. The strategy was right about everything except the one comparison it was built on, and no error was ever raised — the indicator is a perfectly good rolling maximum, just not the one this entry needs. The correction is a reordering: take the maximum of the window as it stood BEFORE this bar, then push. Saved as its own `breakout_level.rhai` and called in place of `donchian_high`, the same profile gives: ```text { "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 } ``` ## Reading that result [#reading-that-result] The strategy trades now, and it loses money. 48 trades over the year, of which 31.25% won. Equity ended at 8334.57 of the 10000 it started with: a total return of -0.1665, which is a 16.65% loss. Max drawdown was 18.40% and Sharpe was -1.05. Profit factor is gross profit over gross loss (`crates/vike-analytics/src/metrics.rs`'s `profit_factor`). At 0.687 it says the winners gave back about 69 cents for every dollar the losers cost. What the numbers say: the script compiles, both user indicators resolve, the entry condition now fires, and the resulting edge is negative on this tape. What they do not say: anything about the idea in general. This is one symbol, one year, one bar interval, one parameter point, measured in-sample with no out-of-sample window. A single backtest number is the weakest evidence the platform can produce, and the honest next step is not to tune `calm` until the number turns green — a sweep will always find the best-looking point on the tape it searched. [Sweep and walk-forward](/docs/trader/tutorials/sweep-and-walk-forward) is where that goes next, and it is the tutorial that shows what an out-of-sample number does to an in-sample one. ## Mounting it live [#mounting-it-live] A script can also mount LIVE, by PATH rather than by name: `[strategy] rhai = ""` in a `vike-tradehub` profile compiles it through the same engine builder and logs the source's sha256, which is the journal's answer to which code traded. ## Where next [#where-next] * [Writing a strategy](/docs/trader/scripting/writing-a-strategy) and the [cheatsheet](/docs/trader/scripting/cheatsheet). * [The engine](/docs/trader/scripting/the-engine) and [Limits](/docs/trader/scripting/limits). * [Writing an indicator](/docs/trader/scripting/writing-an-indicator) — the other seam, a real `Indicator` with `vectorize` parity by construction. * [A custom indicator, end to end](/docs/trader/tutorials/custom-indicator). * [Sweep and walk-forward](/docs/trader/tutorials/sweep-and-walk-forward). # RSI mean-reversion backtest (/docs/trader/tutorials/rsi-mean-reversion-backtest) The quickstart runs `sma_cross`. This tutorial runs the other shipped example — `rsi_meanrev`, which fades extremes instead of following them — because the two make opposite bets and running both over one slice is the quickest way to see what a tape has been doing. No credentials are needed at any step. ## 1. Scaffold the examples [#1-scaffold-the-examples] ```sh vike-cli init ``` `vike_cli::cmd::init` writes `/user_data` and prints the map. Its `files` table is the exhaustive list of what it ships and the only thing `--reset` can overwrite; without `--reset` it writes only what is absent, so a file you edited is never touched, never diffed and never backed up. Two entries matter here: ``` user_data/strategies/rhai/rsi_meanrev/rsi_meanrev.rhai user_data/strategies/rhai/rsi_meanrev/default.toml ``` ## 2. What the script says, and the two lines that are not style [#2-what-the-script-says-and-the-two-lines-that-are-not-style] `content.rs`'s `RSI_MEANREV_RHAI` is eleven lines of body. It declares four knobs at the top level — `len` 14, `lo` 30, `hi` 70, `qty` 1.0 — and then: ```rust fn on_bar() { let r = rsi(len.to_int()); if r.is_nan() { return; } if r < lo && position() <= 0.0 { market(1, qty); } if r > hi && position() >= 0.0 { market(-1, qty); } } ``` **The indicator is read first, on every bar, before any branch.** An indicator only advances when the script calls it, so a call inside an `if` would skip bars and quietly stop being an average of the last N. `content.rs`'s module doc states the rule and `crates/vike-cli/tests/init_cli.rs`'s `every_shipped_rhai_strategy_reads_its_indicators_before_it_branches` gates it for every shipped script. **The `position()` guards are what stop it stacking size** — it enters only from flat or from the other side, so a long stretch below `lo` opens one position rather than one per bar. The `is_nan()` return is the warm-up. `crates/vike-indicators/src/indicators/base.rs`'s `Rsi` is the Wilder RSI — a seeded average of the first `n` gains and losses, then the recurrence — and it answers `NaN` until the bar at index `n`, so a 14-period RSI decides nothing for the first fourteen deltas. `crates/vike-script/src/engine.rs`'s `RHAI_INDICATORS` is the host-bound set, derived from `vike_indicators::registry()` rather than hand-listed. Rhai resolves a name when the line RUNS, so compiling proves nothing: a misspelled indicator errors every bar and, after ten consecutive errors, `RhaiStrategy` self-disables — a strategy that looks mounted and never trades, which a backtest reports as a flat curve. `vike-cli indicators` prints the set that is actually bound. ## 3. Seed the tape [#3-seed-the-tape] ```sh backtest --seed-demo ``` `vike_data::demo`'s `DEMO_SLICES` writes two spans under the venue id `demo`: `BTCUSDT` hourly from 2025-01-01T00:00:00Z to 2025-07-01T00:00:00Z, and the last seven days of the same curve at one minute. Every bar is a closed-form function of its timestamp, so a re-seed is free as well as safe — each slice appends under a versioned commit key and a store that already holds the tape takes zero rows. ## 4. Run it [#4-run-it] The shipped `profiles/backtest.toml` already names that slice. Its `[strategy.params]` carries `sma_cross`'s `fast`/`slow`, which `rsi_meanrev` does not read — and a params table is a READER, not a schema, so those two keys are ignored in silence and every `rsi_meanrev` knob keeps the script's own default. Replace them, or pass the shipped preset: ```sh vike-datahub & vike-cli backtest \ --profile user_data/profiles/backtest.toml \ --preset user_data/strategies/rhai/rsi_meanrev/default.toml \ --script user_data/strategies/rhai/rsi_meanrev/rsi_meanrev.rhai ``` `merge_preset_params` merges a preset's keys into `[strategy.params]` one for one, last-wins, and leaves the rest of the profile untouched. A preset is FLAT: a `[params]` wrapper is refused, because merged as-is it would hand the strategy one parameter called `params` that nothing reads while every knob kept its default, and a `src` key is refused because that is the strategy's source rather than a knob. The preset is resolved locally and merged before the request goes out — only the profile text crosses the wire, so a preset named inside the profile would resolve against the server's `user_data/`, not yours. To see the knobs without a server at all: ```sh vike-cli backtest --list-params --script user_data/strategies/rhai/rsi_meanrev/rsi_meanrev.rhai ``` That runs `vike_script::discover_params`, which compiles the script and executes its top level — where `param(name, default)` is called — and returns the pairs in declaration order. ## 5. What comes back [#5-what-comes-back] On the demo tape, with the preset's 14/30/70 and its `qty = 1.0`: ```text name: rsi_meanrev — demo BTCUSDT 1h final_equity: -133004.57 total_return: -1430.0457% n_trades: 51 win_rate: 0.0000% sharpe: 1.6844 max_drawdown: 1430.7866% per_symbol_pnl: BTCUSDT: -136596.11 ``` That is a real run and it is worth sitting with rather than skipping past. The demo tape is not a one-way line: `crates/vike-data/src/demo.rs`'s `price_at` is a drift plus three superposed cycles — weekly, daily and a fast wobble — so the curve does snap back, and on the weekly leg it snaps back hard. What defeats a short-the-overbought rule here is the drift underneath those cycles. The trend term adds 9.5 an hour over the hourly span's six months, so the tape opens near 42,000 and its trend alone ends near 83,000, and each weekly peak sits above the one before it. The strategy shorts each overbought reading into that rise, and `qty = 1.0` means one whole unit of a five-figure instrument against ten thousand of cash. A win rate of exactly zero over fifty-one trades is not noise; it is a strategy selling a drifting tape with size it cannot carry. Note the Sharpe: **1.6844**, on a run that lost fourteen times the account. A ratio computed on per-bar returns says nothing about whether the account survived, which is why the [report](/docs/trader/analytics/report) carries drawdown and equity beside it and why no single number is the answer. ## 6. The same strategy on real bars [#6-the-same-strategy-on-real-bars] Pull real public hourly bars — no credentials, no venue account: ```sh backtest --fetch binance:BTCUSDT:1h --from 2025-01-01T00 --to 2026-01-01T00 ``` ```text 8760 bars returned, covering 1735689600000..=1767222000000; 8760 rows written ``` Point the profile's `venue` at `binance`, and size it for the account — `qty = 0.05` rather than `1.0`: ```text name: rsi_meanrev — BTCUSDT 1h final_equity: 12128.52 total_return: 21.2852% n_trades: 33 win_rate: 69.6970% sharpe: 1.7800 max_drawdown: 8.3457% per_symbol_pnl: BTCUSDT: 2448.31 ``` Same script, same thresholds, same fee rate. What changed is the tape and the size. Two thirds of the trades win and the worst drawdown is 8.3%. Read that as a demonstration of how much a result depends on its slice, not as evidence the strategy works: one symbol over one year is one sample, and [walk-forward](/docs/trader/tutorials/sweep-and-walk-forward) is the next thing to do to it. ## 7. Where the run went [#7-where-the-run-went] ```text user_data/runs/1788477441-18704-0/ manifest.json report.json ``` The manifest records what produced the numbers — the run id, the profile path and name, the data slice with its venue-tagged series and the store root, and the start and finish timestamps. The report is the metrics above as JSON. [Runs on disk](/docs/trader/backtesting/runs-on-disk) covers the layout. ## 8. Read the report [#8-read-the-report] `BacktestReport` carries the profile name, final equity, total return, trade count, win rate, Sharpe, max drawdown, profit factor, net funding and per-symbol PnL. Two fields are conditional and both tell you something: * `profit_factor` serializes as `null` when it is not finite — the house sentinel is `INFINITY` when there were no losing trades but some profit, and `0.0` when there was neither. * `zero_trade` is present ONLY when the run closed no trades AND its equity never moved. `vike_analytics::zero_trade`'s `ZeroTradeReport::analyze` then ranks the probable causes from counters the run already carried — gate drops, stale-price deferrals, session skips, the warm-up requirement — and `Display` prints that diagnosis instead of an all-zero metrics table. On a mean-reversion strategy the usual cause is thresholds the tape never reached. ## 9. The contrast run [#9-the-contrast-run] Re-run the same profile against `sma_cross` and `ema_trend`. `content.rs` ships all three for one reason: they are three SHAPES — always-in reversal, mean reversion, long-only filter — not three indicators. If the long-only rule beats the always-in one, the short side was costing money. ## Where next [#where-next] * [Sweep, then walk-forward](/docs/trader/tutorials/sweep-and-walk-forward) — search the RSI thresholds, then check the winner out of sample. * [Write a Rhai strategy from scratch](/docs/trader/tutorials/rhai-strategy-from-scratch). * [Read a backtest report](/docs/trader/guides/read-a-report) and [sizing and zero-trade diagnosis](/docs/trader/analytics/sizing-and-zero-trade). * [Presets](/docs/trader/scripting/presets) and [Run profiles](/docs/trader/backtesting/run-profiles). # The A-S maker on a paper book (/docs/trader/tutorials/spread-maker-on-paper) `vike_mm::SpreadMaker` is the reference market maker: a two-sided quoter priced by Avellaneda-Stoikov, with inventory-skew size shaping, a per-side fill-rate circuit breaker, a quote-style registry, own-order-book filtration and an anti-churn refresh gate stacked on top. This tutorial mounts it with `vike_paper::PaperExecutionClient` as the `ExecutionClient` — the real runtime, the real risk gate, the real order path, and no venue. It is written `impl Strategy`, because it needs the tagged-resting-order verbs (tagged submits, in-place modify, per-tag cancel, a signed-position read) that are not on the portable `Broker` surface. `vike_core::LiveBroker` implements `HftBroker`, so the live core mounts it; a plain `Broker`-only backtest engine cannot, which is the type system continuing to document the requirement. ## What it is quoting [#what-it-is-quoting] `crates/vike-mm/src/avellaneda.rs` carries the closed forms, adapted for a bounded price domain: ```text reservation price r = s − q_norm · γ · V optimal half-spread δ_half = ½·γ·V + (1/γ)·ln(1 + γ/κ) effective variance V = min(σ̂²·H, p·(1−p)) ``` `s` is the fair mid, `q_norm` the size-normalised signed inventory, `γ` risk aversion, `κ` the fill-intensity decay, and `H` the effective horizon — `min(τ_hold, T−t)` once a `resolution_ts` is set, and the bare `τ_hold` otherwise, which is the default. The `p(1−p)` Bernoulli cap is the crux for a 0..1 market: skew and spread vanish at the walls and peak at 0.5, which keeps A-S from over-penalising inventory near a wall. That geometry is one case of a `PriceDomain` knob. `UnitInterval` is the default and clamps quotes to `[tick, 1−tick]`; `Unbounded` is the dollar-scale case with no wall clamp, where a quote is bounded only by the standoff and the `bid < s < ask` straddle. Two pairings are load-bearing for a dollar-scale mount: `VarianceMode::RawLocal`, because the Bernoulli cap goes negative for `p > 1`, and an `AsParams::min_half_spread_ticks` floor, because the adverse-selection half-spread is sub-tick at that scale and both sides would otherwise snap to one tick. Every layer above the pricing is inert at its default, byte-identically: the skew intensity at `0.0` makes both size multipliers exactly `1.0`; the breaker's window, threshold and cooldown all at `0` mean it never engages; `QuoteStyle::Mid` reproduces the original midpoint-plus-half-spread pricing bit for bit; own-order filtration is off, so the maker prices straight off the feed; and `RefreshTolerance` is `None`, so every tick re-quotes both sides. Turn one on deliberately, and know that filtration and the styles change pricing bit for bit when you do. ## The mount, and the one non-obvious piece [#the-mount-and-the-one-non-obvious-piece] `vike_run::build_paper_maker_core` is `spawn_core` plus a `StrategyMount(SpreadMaker)` plus `PaperExecutionClient`. The wiring that makes it actually fill is the tick-to-bar bridge: **The maker QUOTES on ticks** (`on_quote_tick` / `on_order_book`), **but the paper client FILLS on CLOSED BARS** (`on_bar`, next-open discipline — the R7 paper law). A venue serving ticks rather than candles would therefore produce a maker that quotes forever and never fills, so the mount synthesises bars: `TickBarSynthesizer` folds the feed mid into an event-time OHLC window and emits a closed `vike_model::Bar` on each interval boundary, and that bar drives the paper book. A maker resting inside the spread is filled exactly when the market oscillates through its quote within a bar — a dip-and-recover fills the resting bid, a spike-and-fall fills the resting ask. It is pure wiring: neither the `DataClient` nor the `ExecutionClient` seam changes. `PaperExecutionClient` fills its resting book with the backtest engine's OWN public primitives, so backtest equals paper by construction and is gated bit for bit by `vike-backtest`'s `tests/r7_gate.rs`. `build_paper_strategy_core_with` also asserts that the mount declares no extra legs: a single-symbol paper book stamps its own symbol onto every fill, so a multi-symbol rehearsal would book both legs under one symbol and look perfectly correct while the live core routed them apart — concealing the very thing the rehearsal exists to catch. ## Running it against a real feed [#running-it-against-a-real-feed] `crates/vike-run/src/bin/polymarket_maker_paper.rs`, behind the `polymarket` feature, is the mount above with the real Polymarket `Feeds` as the tick source. It picks a market with `--auto` (universe selection), `--query` against the Gamma catalog, or a `--token-id` outright, reads the market's `end_date` into the A-S `resolution_ts`, and runs for `--duration-secs`. It logs position, working quotes, paper fills and realized PnL every few seconds, then flushes the last synth bar and shuts the core down cleanly. No API keys are needed: the market channel is public and unauthenticated, and the mount is paper — it never signs or sends an order. Polymarket's CLOB is US-geo-blocked, so the binary must run on, or egress through, a host outside that block; the mount is identical either way, and only the feed differs from the offline scripted test. ## The daemon route, and the trap in it [#the-daemon-route-and-the-trap-in-it] `vike-tradehub` mounts the A-S maker as its DEFAULT: absent a `[strategy]` table it builds one from the profile's own maker fields (`qty`, `half_spread`, `tick_size`, `resolution_ts_ms`, plus `interval` / `interval_ms` for the synthesiser and `seed_cash` for the paper account) through `vike_run::build_maker`. `SpreadMaker::from_params` is a reader over `[strategy.params]` and nothing else, so naming `spread_maker` there would mount `qty = 1`, `tick_size = 0` and the `[0,1]` Bernoulli wall clamp — the exact configuration `vike-tradehub`'s own module doc records as having posted ZERO orders on a five-figure hyperliquid asset. `config.rs`'s `AS_MAKER_NAMES` therefore routes both maker spellings to the same `build_maker` call the default path makes, and `validate` REFUSES a `[strategy.params]` table under either name rather than dropping it. The two spellings cannot differ; an attempt to make them differ is a startup error. That refusal is why the maker's row in `vike_strategy::PARAM_KEYS` is allowed to decline to enumerate its roughly sixty keys: its consumer carries a stricter rule of its own. On a `vike-backtest` profile, where `[strategy.params]` is the only configuration that exists, the reader is live — and it fails loudly on an ENUM key (`spread_model`, `kappa_mode`, `style`, …) that is present and names no variant, so a value typo is a `vike_mm::ParamError` rather than a silent default. ## Where next [#where-next] * [Avellaneda-Stoikov](/docs/trader/market-making/avellaneda-stoikov), [Inventory skew](/docs/trader/market-making/inventory-skew) and [Quote styles](/docs/trader/market-making/quote-styles). * [Refresh tolerance](/docs/trader/market-making/refresh-tolerance) and [Quote emission](/docs/trader/market-making/quote-emission). * [The spread-maker template](/docs/trader/strategies/templates/spread-maker). * [The grid template on paper](/docs/trader/tutorials/grid-on-paper) — the same mount, a simpler strategy. # Sweep, then walk forward (/docs/trader/tutorials/sweep-and-walk-forward) A sweep answers "which parameters scored best on this slice". A walk-forward answers a different question — "was that profitable across the whole sample, or in one lucky stretch" — and it answers it without optimising anything. Running the second after the first is the point of this tutorial; running only the first is how a curve-fit ships. Both profiles are shipped by `vike-cli init` (`profiles/sweep.toml`, `profiles/walkforward.toml`), and both carry their script INLINE in `[strategy.params].src`, because neither the `sweep` nor the `walkforward` verb takes `--script`. `vike-cli init` writes that inlined copy from the same source as `strategies/rhai/sma_cross/sma_cross.rhai`, so a fresh scaffold agrees — edit one afterwards and you must edit the other. ## 1. The sweep [#1-the-sweep] ```toml [sweep] fast = [5.0, 10.0, 20.0] slow = [30.0, 50.0, 100.0] ``` `harness::sweep`'s `expand_sweep` turns a profile carrying that table into the cartesian product of single-run profiles — each with `strategy.params` overridden for its point and its own `sweep` field cleared, so every expanded point is itself a plain runnable single-backtest profile. A profile with no `[sweep]` table expands to exactly one point with no overrides, which is why the non-sweep path is unchanged. ```sh vike-cli sweep --profile user_data/profiles/sweep.toml --rank-by sharpe ``` `run_sweep` runs each point through `run_backtest`, keeps a `BacktestReport` or the error string per point, and ranks the successful rows; failed rows always sort last. `--rank-by` takes `sharpe` (default), `return`, `max_dd` or `equity`. The name is validated locally and applied server-side by the canonical `RankMetric` comparator, so the rows arrive already ordered best-first and the printed statistics are the server's own report numbers. `vike-cli sweep` used to re-parse the profile into `WireSpec`/`WireSlice`/`WireSweep` DTOs. That mapping could not carry `[engine].fee`, `[engine.impact]`, `[engine.resolution]`, `[risk]`, `snap_to_properties`, a tick slice or a cross-venue `[[data.series]]` slice — so a sweep quietly ran under different costs than the same profile's `backtest`. The text is now shipped verbatim and the server's one `BacktestProfile::from_toml_str` parses it. Three properties worth carrying: * **Determinism does not rest on scheduling.** Points run on a rayon pool by default, but `par_iter().map().collect()` is order-preserving and the ranking sort is a stable `slice::sort_by` over that same input order, so the bytes are identical to the one-at-a-time loop (`parallel_and_sequential_sweeps_are_byte_identical`). * **Parallelism multiplies MEMORY, not just speed.** The shared `Arc` shares the handle, not the loaded data: every point re-scans and fully materialises its own `Vec` / `Vec`, so peak RSS is `N_threads × slice`. The pool is therefore bounded rather than global — `sweep_threads` defaults to `min(4, available_parallelism())` and `VIKE_SWEEP_THREADS` raises it. Size that against the slice, not the core count. `VIKE_SWEEP_SEQUENTIAL=1` pins the process back to the old loop. * **Cost multiplies fast.** The shipped grid is 3 × 3 = 9 runs; a fourth axis of five values is 45. Write the values as floats — the shipped profile's own warning — because a non-numeric value is skipped in silence. ## 2. What the sweep actually reported [#2-what-the-sweep-actually-reported] The runs below use a smaller grid than the shipped profile, so the whole table fits on the page: 3 × 2 = 6 points over the scaffolded `rsi_meanrev` script's `len` and `lo` axes. The data is real Binance BTCUSDT 1h bars for the first half of 2025, run at `cash = 10000`, `fee_rate = 0.001` and `slippage = 0`. ```toml [sweep] len = [7.0, 14.0, 21.0] lo = [20.0, 30.0] ``` Through the standalone `backtest` binary: ```sh backtest --profile tut_rsi_sweep.toml --rank-by sharpe ``` ```text sweep ranked by sharpe (6 points) *#1 len=14.0 lo=30.0 ret=0.2129 sharpe=1.7800 max_dd=0.0835 trades=33 #2 len=21.0 lo=30.0 ret=0.1542 sharpe=1.1885 max_dd=0.1341 trades=15 #3 len=14.0 lo=20.0 ret=0.0755 sharpe=0.6565 max_dd=0.1402 trades=12 #4 len=7.0 lo=30.0 ret=0.0246 sharpe=0.2807 max_dd=0.0873 trades=103 #5 len=7.0 lo=20.0 ret=-0.0242 sharpe=-0.1026 max_dd=0.1137 trades=50 #6 len=21.0 lo=20.0 ret=-0.0992 sharpe=-0.6544 max_dd=0.1738 trades=2 ``` Two of the six points lost money, and the table keeps them. Ranking is not filtering: rows 5 and 6 print with their negative returns and negative Sharpes, in last place, because a grid point that failed is evidence about the grid. The winner returned 21.29% at a Sharpe of 1.7800 over 33 trades. Notice where that ordering comes from — every `lo = 30.0` point outranks every `lo = 20.0` point. This is a one-axis result. The grid found an entry threshold that the first half of 2025 rewarded, and `len` only shuffles rows within it. Read the trade counts before the returns. Row 4 traded 103 times for +2.46%; row 6 traded twice for -9.92%. A Sharpe of -0.6544 computed over two trades is a number, not a measurement — and the same caution applies to the winner's 33. What the table does not say is anything out-of-sample. All six points were scored on the same bars, and the top row was then chosen by looking at those scores, so 1.7800 is the maximum of six draws rather than an estimate of what `len = 14`, `lo = 30` will do next. That is the gap the walk-forward exists to measure. ### The same grid through the service [#the-same-grid-through-the-service] `vike-cli sweep` ships the same profile text to a `vike-datahub` server, which parses it with the one `BacktestProfile::from_toml_str` and ranks the rows itself: ```sh vike-cli sweep --profile tut_rsi_sweep.toml --rank-by sharpe ``` ```text parameter sweep — 6 point(s), ranked server-side by sharpe rank overrides final_equity return sharpe max_dd trades 1 len=14.0, lo=30.0 12128.52 +21.29% 1.7800 0.0835 33 2 len=21.0, lo=30.0 11542.23 +15.42% 1.1885 0.1341 15 3 len=14.0, lo=20.0 10755.26 +7.55% 0.6565 0.1402 12 4 len=7.0, lo=30.0 10246.32 +2.46% 0.2807 0.0873 103 5 len=7.0, lo=20.0 9758.30 -2.42% -0.1026 0.1137 50 6 len=21.0, lo=20.0 9008.27 -9.92% -0.6544 0.1738 2 ``` Same six points, same order, same numbers. Only the presentation differs: the standalone table prints `ret` as a fraction where the service table prints a percentage, and the service table carries `final_equity` as well. Neither client computes a statistic. `print_sweep` in `crates/vike-backtest/src/harness/sweep.rs` reads each field straight out of the server's own row, and the standalone table is `SweepReport`'s `Display` in `crates/vike-backtest/src/harness/sweep.rs`. The star on the standalone table's first row marks rank 1. ## 3. The walk-forward [#3-the-walk-forward] Take the winning row's parameters, put them in `[strategy.params]`, and run: ```sh vike-cli walkforward --profile user_data/profiles/walkforward.toml ``` `harness::walkforward`'s `run_walkforward` is assembly, not new engine code: it resolves the strategy through the same `strategy_by_name` registry, builds the same bar-lane `EngineParams` and loads the same bars as a plain `run_backtest` of that profile, then hands the series to `walkforward::walk_forward_strategy`. So a walk-forward can never disagree with a single run about what the profile's `[engine]` means — including the fee SCHEDULE and `[risk]`, which the old DTO-shaped path could not carry. It is BAR mode over ONE series, deliberately: the splitter divides a single `&[Bar]` by index, so a tick or multi-symbol profile is a clean validation error rather than a silent first-symbol fallback. `[walkforward] n_splits = 4` is the whole configuration. `walk_forward_splits` cuts the series into `n_splits + 1` chunks; window `s` tests `[s·chunk, (s+1)·chunk)` with the last window running to the end, and under `Anchored` mode every window's training range starts at index 0. Each window runs fresh at `[engine] cash` and the OOS curves are rebased onto one running equity, so the stitched curve compounds. The parameters are fixed and identical in every window. This is an out-of-sample STABILITY check, not an optimise-then-test protocol — which is exactly why you run it on the sweep's winner rather than instead of the sweep. `WalkForwardReport` returns the per-window `oos_return`s, the stitched `oos_equity_curve`, its `oos_return` and `oos_sharpe`, and `wf_consistency` — the fraction of windows that were profitable out of sample. That last number is the one to look at first: a strong stitched return with a consistency near 0.25 is one window carrying the strategy. Point both profiles' `[data]` at a slice your store actually holds before the first run. The shipped sweep names `binance` over the demo tape's dates, and the shipped walk-forward spans 2024 to 2026; an empty slice is a run with no trades, which reads like a bad strategy rather than an empty query. ## 4. What the walk-forward actually reported [#4-what-the-walk-forward-actually-reported] The profile below carries the sweep's winning row — `len = 14.0` and `lo = 30.0` — over the identical `[data]` and `[engine]` block, plus `[walkforward] n_splits = 4`. The `--addr` flag defaults to `127.0.0.1:7878`, so a datahub on the same box needs no flag. ```sh vike-cli walkforward --profile tut_rsi_wf.toml ``` ```text walk-forward: 4 out-of-sample window(s) window test_range oos_return 1 [869, 1738) +11.27% 2 [1738, 2607) +5.01% 3 [2607, 3476) -3.44% 4 [3476, 4345) +6.05% oos_return = +19.64% oos_sharpe = 1.983 wf_consistency = 75.0% ``` Window 3 lost 3.44%. That is the result, not a run to repeat until it behaves: one of the four out-of-sample stretches of the first half of 2025 was a losing stretch for these parameters. `wf_consistency = 75.0%` is that same fact counted — three of four windows finished above the cash they started with. `walk_forward_strategy` in `crates/vike-backtest/src/walkforward.rs` counts a window profitable on a strictly positive `oos_return`, so a perfectly flat window would count against it too. The index ranges are the anchored splitter showing its work. The series is 4345 bars; `walk_forward_splits` in `crates/vike-analytics/src/validation.rs` sets `chunk = n / (n_splits + 1)` = 869, window `s` tests `[s·869, (s+1)·869)`, and the last window runs to the end. So the first 869 bars are never tested. They are training-only in every window — that is what anchored mode buys, and it is also what the stitched number is missing. Which makes +19.64% not comparable with the +21.29% of the sweep's top row. The sweep scored the whole slice; the walk-forward scored the last four fifths of it. The same goes for `oos_sharpe = 1.983`, computed on the stitched curve and so covering the same four windows. The two returns landing near each other is mildly reassuring and nothing more. What the run does not say is that the strategy is stable. Four windows is four observations, and 75% and 100% are one window apart. At `n_splits = 4` over six months of hourly bars each window is about five weeks — short enough for a single trend leg to fill one. Raising `n_splits` buys more windows, not more data. Nor is this a forward test. These parameters were chosen by looking at these same bars in the sweep above, so the selection has already seen every window it is now scored on. The honest reading is narrow: with `len = 14` and `lo = 30` held fixed, this slice produced three up windows and one down one. ## Where next [#where-next] * [Sweeps](/docs/trader/backtesting/sweeps), [Search](/docs/trader/backtesting/search) and [Walk-forward](/docs/trader/backtesting/walk-forward). * [Bayesian parameter search](/docs/trader/tutorials/tpe-parameter-search) — when the grid is too big to enumerate. * [Overfitting](/docs/trader/analytics/overfitting) and [Monte Carlo and overfitting](/docs/trader/tutorials/monte-carlo-and-overfitting). * [Sweep on the service](/docs/trader/guides/sweep-on-the-service). # Tick replay with a queue model (/docs/trader/tutorials/tick-replay-with-queue-model) The default backtest fills a resting limit the instant price touches it. `crates/vike-backtest/src/queue_model.rs` says what that means and why it is wrong for a maker: the frozen crossing model behaves *as if the order were always first in the queue*, so it over-fills. On a real venue an order at price P fills only after the resting size **ahead** of it at P has been consumed. This tutorial turns that estimate on. ## The profile [#the-profile] Everything here is [tick mode](/docs/trader/backtesting/tick-replay). Two knobs and a floor: ```toml [data] kind = "tick" from = "1775000000000" to = "1775002000000" [[data.series]] venue = "binance" symbol = "BTCUSDT" [engine] cash = 10000.0 slippage = 0.0 fill_model = "l2book" queue_model = "risk_adverse" queue_seed_depth = 0.0 queue_min_hold_ms = 2000 ``` `queue_model`, `fill_model`, `queue_seed_depth`, `queue_min_hold_ms`, `feed_latency`, `order_latency_ms`, `fill_latency_ms` and `equity_sample_every` are **tick-mode only**. A bar profile that sets `queue_model` is rejected by `BacktestProfile::validate` rather than silently ignored, and an unrecognised value is a fail-fast validation error at load — a typo like `"l2_book"` must not quietly select the optimistic model and undo the realism knob. Absent, the whole lane is off and the run is byte-identical to the frozen crossing fill. ## What the gate actually does [#what-the-gate-actually-does] `crates/vike-backtest/src/engine/queued.rs` is reached **only** when `EngineParams::queue_model` is `Some`; a default run never enters that module at all. The composition rule is stated in the queue model's own header and is worth reading as a sequence, because the queue gate layers on top of the price law rather than replacing it: * the existing fill model still decides the **price** condition (limit crossed or touched); * a **strict cross** — a trade or quote that trades *through* the level — fills in full: everyone at the level, the queue ahead included, was consumed; * a **touch** at exactly 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, producing partial fills; 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 the wait — the depth-consumption channel the crossing model ignored entirely. That last point is the reason to record the **book** and not just the trade tape. `queue_seed_depth` is the fallback front used when neither the replayed book nor an L1 quote gives a size at the order's price, and its default is `0.0` — a quote with no observed size-ahead fills on the first at-price trade, which is the optimistic answer again. Feed the book, or set the floor deliberately. ## Choosing a model [#choosing-a-model] Three values, case-insensitive: * **`risk_adverse`** — the conservative bound. The front shrinks only on hard evidence: trades at the order's price, or the level itself shrinking below the current front estimate. Cancellations behind the order are never credited, so this is the pessimistic, slowest-fill estimate. * **`prob_power`** / **`prob_power:N`** — probabilistic, with weight `f(x) = x^N`. A net depth decrease is split between the queue ahead and behind by `prob = f(back) / (f(back) + f(front))`, the probability the cancellation came from behind. `N = 1` splits a decrease pro rata by size; larger `N` biases attribution toward the (usually larger) back. * **`prob_log`** — the same split with the sub-linear weight `f(x) = ln(1 + x)`, so large backs saturate. `ProbFunc::f` calls `libm::pow` and `libm::log` — never `x.powf(n)` or `.ln()`, which resolve to the platform's own libm and disagree in the last bit between a Windows CRT and glibc. What that bit decides here is a **fill**: the weight moves the estimated queue ahead of a resting maker, which moves whether a trade's excess reaches the order at all, so a backtest can fill on one box and not the other. Decision 0032 carries the measurement. The bare `"prob_power"` spelling escapes it — it parses to `Power(1.0)` and libm's `pow` returns `x` unchanged for `y = ±1` ahead of any arithmetic — so the unconditional exposure is `"prob_log"` plus `"prob_power:N"` for any `N ≠ 1`. ## The minimum hold [#the-minimum-hold] `queue_min_hold_ms` defers a position-**reducing** fill on the **tagged** maker lane until that long after the position opened, so the backtest cannot fabricate a sub-second maker round trip; the untagged queued-limit lane runs no such check at any setting. It is consulted only when `queue_model` is set, defaults to `0` (off), and its own doc names where to get the number: calibrate from real market-making flip times — the reference measurement is a Polymarket BTC-5m ≈ 2 s at the 10th percentile. A maker result with no floor at all is a result that assumes it could re-quote instantly. Pair `fill_model = "l2book"` with `slippage = 0.0`: that model fills a resting order only up to the displayed book depth and rests rather than filling size the market never showed, so the book walk *is* the slippage and a flat rate on top double-counts. ## Verify it [#verify-it] Run the same profile twice, once with `queue_model` absent and once set, and compare `n_trades` and `final_equity`. The difference is the whole point, and it should be large for a passive strategy. Then read the honesty note the shipped `crates/vike-backtest/profiles/wf_grid.toml` writes about its own bar-mode grid, because it names the ceiling this page exists to lift: > NO QUEUE POSITION, AND NO WAY TO ADD ONE ON THIS LANE… a bar-mode grid fills EVERY rung the bar's high/low merely TOUCHED, at its own limit price, in full size. Real passive fills happen only when someone crosses you, and the ones you get are adversely selected. This is the single largest optimism in the run and it cannot be modelled here — a tick/book profile with `queue_model` + `queue_min_hold_ms` is the only honest version of a maker backtest. Next: [queue models](/docs/trader/backtesting/queue-models) and [the queue lane](/docs/trader/backtesting/queue-lane) for the reference pages, [latency models](/docs/trader/backtesting/latency-models) for the reaction-time half, and [record and replay](/docs/trader/tutorials/record-and-replay) for getting a book on disk in the first place. # Bayesian parameter search (/docs/trader/tutorials/tpe-parameter-search) An exhaustive grid costs exponentially in resolution. Two searches in the harness buy the same final resolution for far fewer backtests, and they buy it in different ways: `--search euler` refines locally around the grid's winner, while `--optimizer tpe` models where good scores live and proposes there. Both read the SAME `[sweep]` table, both score through the same objective seam, and both return the same ranked `SweepReport` the grid returns. They live on the standalone runner, which opens the store itself: ```sh backtest --profile user_data/profiles/sweep.toml --optimizer tpe --trials 120 --seed 7 backtest --profile user_data/profiles/sweep.toml --search euler --euler-depth 4 ``` The rest of this page runs all three paths over ONE profile so the numbers are comparable, and prints what each returned. That profile sweeps two numeric axes of an RSI mean-reversion strategy on BTCUSDT 1h bars. ## The grid these searches are measured against [#the-grid-these-searches-are-measured-against] A search is only interesting against the number it is trying to beat, so start with the exhaustive grid. Three lengths by two oversold thresholds is six backtests: ```sh backtest --profile tut_rsi_sweep.toml --rank-by sharpe ``` ```text sweep ranked by sharpe (6 points) *#1 len=14.0 lo=30.0 ret=0.2129 sharpe=1.7800 max_dd=0.0835 trades=33 #2 len=21.0 lo=30.0 ret=0.1542 sharpe=1.1885 max_dd=0.1341 trades=15 #3 len=14.0 lo=20.0 ret=0.0755 sharpe=0.6565 max_dd=0.1402 trades=12 #4 len=7.0 lo=30.0 ret=0.0246 sharpe=0.2807 max_dd=0.0873 trades=103 #5 len=7.0 lo=20.0 ret=-0.0242 sharpe=-0.1026 max_dd=0.1137 trades=50 #6 len=21.0 lo=20.0 ret=-0.0992 sharpe=-0.6544 max_dd=0.1738 trades=2 ``` Six evaluations, best sharpe 1.7800 at `len=14 lo=30`. Two of the six points LOSE money — #5 returns -2.42% and #6 returns -9.92% — and the grid keeps them and ranks them last. That matters more than it looks. The same two rows reappear at the bottom of the euler run below, and a reader who only ever sees a winner never learns how much of this space is bad. The grid rows print no `score=` column. Both search outputs below do, and on every one of their rows the score equals that row's sharpe, because `--rank-by` picks the objective as well as the ranking. ## The space both searches read [#the-space-both-searches-read] `numeric_sweep_axes` is the one `[sweep]`-table reader, shared by the grid and both searches. Every axis must be numeric AND type-homogeneous — an all-integer or an all-float TOML array. A string or bool axis has no notion of halfway between two values, and a mixed `[1, 2.5]` axis would silently change the TOML type the strategy receives versus the grid path, so a strategy reading its param through `as_integer()` would behave differently under a search than under `--search grid`. Both are a clean validation error telling you to use the grid. For TPE, `tpe_space_from_profile` then turns each axis into a CONTINUOUS range bounded by that axis's min and max, with integral axes staying whole. So `fast = [5.0, 10.0, 20.0]` does not mean "try these three" — it means "search 5 to 20". TPE explores BETWEEN the grid points, the same philosophy as euler's halving. The grid above prints `len=14.0` and `lo=30.0`, decimal points and all, so both axes here are float arrays. TPE therefore receives a continuous `len` over 7 to 21 and a continuous `lo` over 20 to 30. The six grid points are six particular points inside that box, not the box itself. ## What TPE actually does [#what-tpe-actually-does] `harness::tpe` is a clean-room implementation of Bergstra, Bardenet, Bengio and Kégl's Tree-structured Parzen Estimator (NeurIPS 2011, §4), restated for this workspace's maximize convention: 1. Keep the observation history `{(x, score)}`. 2. Split it at the `gamma` quantile of score — the top fraction is GOOD, the rest BAD. 3. Per parameter, independently, build two Parzen density estimates: `l(x)` from the good observations and `g(x)` from the bad ones. 4. Expected improvement is monotone in `l(x)/g(x)`, so propose the `x` that maximises the ratio. For a continuous parameter that means drawing `n_candidates` samples from `l` and taking the argmax of `l/g`. A continuous parameter uses an adaptive Parzen estimator — one Gaussian per observation plus a broad prior Gaussian at the domain midpoint, each kernel's bandwidth the maximum distance to its sorted neighbours, clipped so it can neither collapse to a spike nor exceed the domain width. The first `n_startup` asks are seeded-random, to give the model something to fit. `TpeConfig` publishes the defaults: `DEFAULT_TRIALS` 64, `DEFAULT_GAMMA` 0.25, `DEFAULT_STARTUP` 10, `DEFAULT_CANDIDATES` 24. `--trials` and `--seed` (default 0) are the two the CLI exposes. ## Running the TPE search [#running-the-tpe-search] Same profile, same objective, twelve trials at seed 7: ```sh backtest --profile tut_rsi_sweep.toml --optimizer tpe --trials 12 --seed 7 ``` ```text tpe: 12 trials (seed 7), best score 1.4950 sweep ranked by sharpe (12 points) *#1 len=19.024470417556497 lo=26.62415622170128 ret=0.1986 sharpe=1.4950 max_dd=0.1083 trades=16 score=1.4950 #2 len=19.610649528496367 lo=25.829302930280782 ret=0.1582 sharpe=1.2327 max_dd=0.1058 trades=14 score=1.2327 #3 len=19.096107272110384 lo=25.4828741659996 ret=0.1582 sharpe=1.2327 max_dd=0.1058 trades=14 score=1.2327 #4 len=19.852274192045854 lo=28.71331759876744 ret=0.1536 sharpe=1.1804 max_dd=0.1102 trades=16 score=1.1804 #5 len=15.667688416361274 lo=27.573219822649627 ret=0.1213 sharpe=1.0531 max_dd=0.1313 trades=23 score=1.0531 #6 len=13.334186530160558 lo=22.49431522282743 ret=0.1224 sharpe=0.9732 max_dd=0.1434 trades=19 score=0.9732 #7 len=13.551342059120229 lo=23.28076739152503 ret=0.1155 sharpe=0.9328 max_dd=0.1441 trades=21 score=0.9328 #8 len=21.0 lo=27.077044141294163 ret=0.1059 sharpe=0.8671 max_dd=0.1285 trades=12 score=0.8671 #9 len=8.87961618331828 lo=24.131413974177793 ret=0.0763 sharpe=0.6776 max_dd=0.1019 trades=57 score=0.6776 #10 len=8.449839262830166 lo=29.598740765730916 ret=0.0504 sharpe=0.4758 max_dd=0.1085 trades=77 score=0.4758 #11 len=19.31459176678944 lo=23.263613015537427 ret=0.0410 sharpe=0.3962 max_dd=0.1278 trades=7 score=0.3962 #12 len=12.457616477477801 lo=20.167882945281562 ret=0.0307 sharpe=0.3180 max_dd=0.1497 trades=17 score=0.3180 ``` The first line is the run summary. It goes to stderr, so a `--json` stdout stays a clean document. Read the coordinates before you read the scores. Every row is a fresh real number rather than a grid value, which is the continuous box doing exactly what the section above said it would. The one round coordinate is `len=21.0` on row #8, and that is the axis's upper bound. Twelve trials is a small budget deliberately, and it is smaller than it looks. `DEFAULT_STARTUP` is 10, and `TpeOptimizer::ask` draws seeded-random coordinates until the history reaches that many. So ten of these twelve asks were random and exactly two were model-driven proposals. This run sits much closer to random search than to a TPE run at its intended budget. Two coincidences in the table are worth a second look. Rows #2 and #3 sit at different coordinates and report identical ret, sharpe, max\_dd and trades. So does row #5, against a point the euler run below reaches from the other direction. A period has to become a whole number of bars somewhere, and a threshold only bites when an observation crosses it, so the continuous box contains flat plateaus. Some of the resolution TPE is paying for is resolution this strategy cannot use. ## Reproducibility, and the bug that was in this contract [#reproducibility-and-the-bug-that-was-in-this-contract] Every stochastic step draws from a seeded `SplitMix64` owned by the optimiser, so the same seed over the same space and the same data produces the same proposal sequence. `SplitMix64` rather than `rand`'s `StdRng` is deliberate: `StdRng`'s algorithm is not guaranteed stable across versions, while a ten-line generator is fixed forever. The integer stream was never the leak — SplitMix64 is exact `u64` arithmetic. The leak was the two float transforms on top of it: Box–Muller's `ln` and `cos`, and `gaussian_pdf`'s `exp`. IEEE 754 requires nothing of those, so an `f64` method call reaches the platform's libm and MSVC and glibc disagree in the last bit. One perturbed draw moves a sampled coordinate, which moves which candidate wins the argmax, which moves the proposal, which moves the history every later proposal is built from — the sequences FORK rather than drift. All three transcendentals now call the `libm` crate, one implementation compiled into the binary. Both `sqrt` calls are deliberately left as `f64` methods, because IEEE 754 does require `sqrt` correctly rounded. ## Comparing against successive halving [#comparing-against-successive-halving] `--search euler` evaluates the coarse grid once, then repeatedly halves the per-axis step and re-searches only the immediate neighbourhood of the current best point, clamped to each axis's range and deduped against everything already evaluated. After `D` halvings the effective resolution is that of a grid with `2^D·(n−1)+1` values per axis; euler pays at most `N + D·(3^d − 1)`. For a two-axis 5 × 5 grid at depth 3 that is at most 49 evaluations against 1089 for the equivalent-resolution grid. The same profile, halved twice: ```sh backtest --profile tut_rsi_sweep.toml --search euler --euler-depth 2 ``` ```text euler: 16 evaluations at depth 2/2 (grid at the depth reached would be 45) sweep ranked by sharpe (16 points) *#1 len=14.0 lo=30.0 ret=0.2129 sharpe=1.7800 max_dd=0.0835 trades=33 score=1.7800 #2 len=17.5 lo=25.0 ret=0.1811 sharpe=1.4293 max_dd=0.1336 trades=16 score=1.4293 #3 len=10.5 lo=30.0 ret=0.1600 sharpe=1.3415 max_dd=0.0830 trades=57 score=1.3415 #4 len=12.25 lo=27.5 ret=0.1578 sharpe=1.3370 max_dd=0.0830 trades=36 score=1.3370 #5 len=12.25 lo=30.0 ret=0.1668 sharpe=1.3085 max_dd=0.0833 trades=43 score=1.3085 #6 len=14.0 lo=27.5 ret=0.1406 sharpe=1.2094 max_dd=0.1148 trades=27 score=1.2094 #7 len=21.0 lo=30.0 ret=0.1542 sharpe=1.1885 max_dd=0.1341 trades=15 score=1.1885 #8 len=14.0 lo=25.0 ret=0.1273 sharpe=1.0656 max_dd=0.1365 trades=21 score=1.0656 #9 len=15.75 lo=27.5 ret=0.1213 sharpe=1.0531 max_dd=0.1313 trades=23 score=1.0531 #10 len=17.5 lo=30.0 ret=0.0980 sharpe=0.8521 max_dd=0.1192 trades=21 score=0.8521 #11 len=15.75 lo=30.0 ret=0.0785 sharpe=0.7099 max_dd=0.0934 trades=26 score=0.7099 #12 len=14.0 lo=20.0 ret=0.0755 sharpe=0.6565 max_dd=0.1402 trades=12 score=0.6565 #13 len=10.5 lo=25.0 ret=0.0606 sharpe=0.5592 max_dd=0.0880 trades=38 score=0.5592 #14 len=7.0 lo=30.0 ret=0.0246 sharpe=0.2807 max_dd=0.0873 trades=103 score=0.2807 #15 len=7.0 lo=20.0 ret=-0.0242 sharpe=-0.1026 max_dd=0.1137 trades=50 score=-0.1026 #16 len=21.0 lo=20.0 ret=-0.0992 sharpe=-0.6544 max_dd=0.1738 trades=2 score=-0.6544 ``` The budget line is those two formulas made concrete. Three values and two values per axis at depth 2 reach a grid of 9 × 5 = 45 points, the bound `N + D·(3^d − 1)` is 22, and the run spent 16. `EulerBudget`'s `equivalent_grid` is derived from the depth REACHED rather than the depth asked for, so that 45 is a comparison the run actually earned. `--euler-depth` defaults to `EulerConfig::DEFAULT_MAX_DEPTH` (3) and is capped at 16. A halving that fails to improve the incumbent is NOT a stop condition — only a smaller step can reach the points between the incumbent and the ones just rejected — so the search runs its full budget and stops early only when a depth produces no unseen candidate at all. The `EulerBudget` line on stderr compares against the grid matching the depth ACTUALLY reached, so an early-stopped run cannot advertise a saving it did not buy. The price is that euler is a LOCAL refinement: it can only descend into the basin its coarse grid already found. Keep the coarse grid wide and treat it as a refinement OF a grid, not a replacement for one. TPE has no such constraint, and correspondingly no guarantee that it visited the coarse grid at all. Scoring is shared: `--rank-by` doubles as the objective for both searches, a row whose backtest succeeded carries a `score` while a failed one keeps `None` and serializes no `score` key at all, a failed point steers as `NaN` and is unrankable, and a `NaN` candidate never displaces a finite incumbent — though euler's first evaluated point becomes the incumbent whatever it scored, so an all-`NaN` search still refines around one. Note that none of the three search paths PERSISTS a run — only a single run writes `manifest.json` and `report.json` under `user_data/runs//`. ## What the three runs actually say [#what-the-three-runs-actually-say] | search | evaluations | best sharpe | at | | ---------------------- | ----------- | ----------- | -------------------- | | grid | 6 | 1.7800 | len=14.0 lo=30.0 | | tpe, 12 trials, seed 7 | 12 | 1.4950 | len=19.02… lo=26.62… | | euler, depth 2 | 16 | 1.7800 | len=14.0 lo=30.0 | Neither search beat the grid. TPE spent twice the grid's budget and came back roughly a third of a sharpe point worse. Euler spent nearly three times the budget and returned the grid's own answer — the same coordinates, the same 1.7800 — so its ten extra half-step evaluations found nothing better than the point it started from. That is the result, and it should stay uncomfortable. A page that ran these three searches and reported an optimiser win would be reporting a run that did not happen. Why TPE lost is not mysterious. The best point in this space IS a grid point, and TPE searches the continuous box between grid points, so it can approach `len=14.0 lo=30.0` and never land on it. Its two nearest rows, #5 and #6, both score under 1.1. Turn that around and you have the case FOR TPE: had the good point sat between grid points, the grid could not have reported it at all, and both searches could. Four things these runs do NOT say: * **Not that TPE is worse than a grid.** One seed, one two-axis space, one tape, one objective. Every proposal moves when the seed moves, which is the whole point of the reproducibility section above, so this is a single draw rather than a measurement. * **Not that TPE was given a fair budget.** `DEFAULT_TRIALS` is 64. This ran 12, of which 10 were the random startup, so the model made two proposals. * **Not that euler wasted ten evaluations.** A refinement that confirms the incumbent has told you the incumbent survives a finer sweep. That is information; it is just not a better number. * **Not that 1.7800 is an edge.** It is the best of six in-sample numbers on one symbol, one interval and one window, picked by looking at all six. Taking the maximum of a set is how you buy a number that does not survive contact with new data. The next step is therefore not a bigger search. It is holding that winner out of sample — see [Sweep, then walk forward](/docs/trader/tutorials/sweep-and-walk-forward). ## Where next [#where-next] * [Search](/docs/trader/backtesting/search), [Sweeps](/docs/trader/backtesting/sweeps) and [Objectives](/docs/trader/backtesting/objectives). * [Sweep, then walk forward](/docs/trader/tutorials/sweep-and-walk-forward) — validate what a search found. * [Overfitting](/docs/trader/analytics/overfitting). # Fit a model and read what it learned (/docs/trader/tutorials/train-a-model) Vike Trader splits machine learning down one line: **fitting drives a child process, inference is pure Rust**. `crates/vike-ml/src/cli.rs` argues the first half — a measured bakeoff disqualified every Rust binding because they drop `categorical_feature` at dataset construction *silently* and then record it in the saved model's parameter block anyway, an artifact that lies to an auditor — and driving the upstream binary costs no crate, no FFI, no `unsafe`, and nothing in the supply-chain audit surface. `crates/vike-ml/src/infer.rs` and `importance.rs` are the second half: pure functions over the model **text**, working identically everywhere. That split decides where you can stand. The tool provisioning skips the LightGBM binary on every non-Linux host, because it is a Linux x86-64 executable and fetching it onto Windows or macOS "installs a file nothing there can execute" — you can name it explicitly to stage a project folder for a Linux box. So: fit where the binary runs, read the model anywhere. ## Fit it [#fit-it] `vike-study` is the study runner that can fit. It is feature-gated (`study-cli`) and lives beside the function it calls rather than in the CLI, which is deliberately kept free of the DataFusion dependency this needs. ```text vike-study --study NAME --recipe FILE --from WHEN --to WHEN --store DIR [--lightgbm PATH] [--scratch DIR] [--runs-root DIR] ``` Three of those flags carry a decision: * **`--lightgbm PATH`** — the pinned trainer. Omitted, the study runs with **no learner**, and a fitting study refuses **by name** rather than silently producing nothing. Before this binary existed nothing in the workspace could fit a compiled study at all: the Studio's ML surface is the inference half only, so it supplies no learner and a study needing a classifier per fold answered `NoLearner` on every invocation. * **`--scratch DIR`** — per-fit scratch, defaulting under the **project folder, never the system temp directory**. * **`--recipe FILE`** — the study's own TOML, which may carry a `[learner]` table. That table exists because a study's non-searched LightGBM parameters are a property **of the study** — the keys its published numbers were measured at — while the learner type knows nothing about any study. In the cohort study those five keys had no home a host could reach: they lived in a test-only module, so any real run would have used LightGBM's defaults and quietly fitted a different model than the one every recorded number came from. An absent table means the trainer's own defaults, which is the right answer for a caller with no opinion and the wrong one to assume in silence — so **the run prints which of the two it used**. ## The pins, and what they do not catch [#the-pins-and-what-they-do-not-catch] Two constants decide whether a model file and this crate are talking about the same thing: `MODEL_VERSION`, the text's own `version=` line, and `PINNED_LIGHTGBM_TAG`, the upstream release the binary was built from, asserted at startup by `LightGbmCli::new`. `lightgbm --version` does not exist — upstream's `main.cpp` has no version handling, and the flag just produces an unknown-parameter warning and a non-zero exit. So the check reads the `tag=` line out of the `PROVENANCE` file the build recipe writes beside the binary. A binary with no `PROVENANCE` beside it — the ordinary state of one built by hand — is a refusal naming both paths and the recipe to fix it, never a panic. The honest weakness, stated in the module rather than implied: that file is self-reported by our own recipe and only its `tag=` line is ever read. The deeper residual is worth carrying before you trust a version check: `version=v4` does **not** change when fields are added within v4, and the parser tolerates unknown `key=value` lines silently — it must, since LightGBM already writes several the walker has no use for. A new per-node array with routing meaning would be read as noise, dropped, and every prediction would be quietly wrong while the version check stayed green. The equality gate that trains and predicts through both implementations, not the version check, is the real proof. ## Packing the data [#packing-the-data] `TrainData::new(x, y, n_cols)` validates the shape **once, here**, rather than letting a child process discover it and report it as a parse error forty lines into a file. `x` is row-major `n_rows × n_cols` `f64`; `y` is one `f32` label per row, because `f32` is LightGBM's own label width. `with_categorical` declares categorical columns and refuses an index outside a row. `GbdtParams` spells LightGBM's own parameter names, and each field is rendered as one `key=value` line. LightGBM splits list parameters on commas but does **not** strip brackets: `categorical_feature=0,3` names two columns, while `categorical_feature=[0,3]` — the shape anyone reaches for who has used the Python API — names *nothing*. The column would then be binned as numerical, nothing would error, and the saved model's `parameters:` echo would still claim it was categorical. The defence is a type rather than a comment: the field is an `Option`, so a list cannot be written, and `categorical_feature_csv` is how a list of indices becomes one. Indices are 0-based over feature columns; the label column is not counted. The five searched axes travel separately as a `GridPoint`: `num_leaves`, `max_depth`, `min_data_in_leaf`, `learning_rate`, `feature_fraction`. `DEFAULT_POINT` is `15 / 4 / 20 / 0.05 / 0.7` — conservative on every axis relative to LightGBM's own `31 / -1 / 20 / 0.1 / 1.0` — and its doc is emphatic that it is a **fallback, not a recommendation**: a caller that reaches it has learned nothing about its data. ## Read what came out [#read-what-came-out] `crates/vike-studio-core/src/ml.rs`'s `ScoringModel` is the whole reading surface. `from_path` or `from_text` parses the walked model **and** its importance table in one load, and refuses as one — a text whose two halves disagree is not half-loaded. Then: * **`score_rows(flat_x, n_features)`** turns a row-major matrix into the probability series, checking the stride once for the whole matrix and refusing a length that is not a whole number of rows. * **`score_row(row)`** is the checked single-row door. It refuses a row narrower than the model, because the walker substitutes `NaN` for a column the row does not carry, routes that through the tree, and returns a probability that *looks* like a probability. A wider row is fine; its trailing columns are ignored. * **`importance_ranked()`** returns `FeatureWeight` rows — index, optional name, `gain`, `splits` — ordered descending by gain with ties broken by ascending index, compared with `total_cmp` so a `NaN` out of a doctored text sorts to one end instead of corrupting the sort. `importance_ranked` deliberately keeps the zero rows. "This input mattered not at all" is an answer, and it is the half of the report that tells you what to stop computing. Names are the last thing to check. The training CSV this crate writes is headerless, so LightGBM auto-generates `Column_0`, `Column_1`, … and index-to-name mapping belongs to whoever packed the matrix. A model naming **no** column is therefore accepted and every weight's name is `None`; one naming **some** is refused, because that is the shifted-table shape, and a misaligned importance table attributes the model's behaviour to the wrong feature — worse than no table at all. Next: [models](/docs/trader/research/models) for the learner seam, [training](/docs/trader/research/training) and [inference](/docs/trader/research/inference) for each half, [feature importance](/docs/trader/research/feature-importance) for the parse, and [model pins](/docs/trader/research/model-pins) for the bump procedure. # Troubleshooting (/docs/trader/troubleshooting) Every entry is *symptom → cause → fix*, the symptom quoted as it appears in the log or on screen. Two rules explain most of them. Absent credentials **are** the live gate: a venue with no keys stays paper, silently, by design. And every misconfiguration splits two ways — **refuse** when a silent difference would leave you believing a protection is armed when it is not (a ceiling, an arming flag), **degrade** when "unconfigured" is a state you may have chosen (a venue connection, a feed, a credential set). `vike-cli config check` states that rule and applies it finding by finding, one exit code for the lot (`crates/vike-cli/src/cmd/config_check.rs`). Every refusal fires before any venue is mounted; a degrade may only *reduce* the program's authority, never redirect it. `vike-desktop`, `vike-tradehub` and `vike-recorder` print what they resolved once a subscriber exists (`crates/vike-config/src/boot.rs`'s `boot_lines`): the settings directory, one line per settings file, every `policy.*` ceiling, and whether a credential store was found beside them — by presence, never by contents. Most "why is it paper" questions are answered there; `vike-cli config show` prints the rest. ## Install [#install] * **`version 'GLIBC_2.39' not found` at exec (Linux).** The release binaries are glibc-dynamic on the release runner, measured at 2.39, and an older host fails at exec rather than at build (`deploy/docker/Dockerfile`'s base-image note). Fix: a host at 2.39 or newer, or the [container](/docs/trader/getting-started/install-docker). * **`status=203/EXEC` from systemd.** The unit's `ExecStartPre=` runs `bin/vike-cli config check`, so the daemon needs that second binary beside it (`deploy/vike-datahub.service`). * **`status=226` from systemd.** The system manager refuses a unit whose `ReadWritePaths=` names a missing path — `/settings/state`. A `--user` manager does not refuse, so a clean `--user` start proves nothing. Fix: create the directory. * **Container: every venue on paper, no error.** Docker's default working directory is `/` and an unmounted `/project` is an empty directory, so the settings walk finds nothing; `deploy/docker/entrypoint.sh` refuses this and names the `-v` flag. From Git Bash on Windows, MSYS path conversion rewrites the container side of `-v C:\vike-trader:/project` to `…:C:/Program Files/Git/project`. Fix: PowerShell or WSL, or `MSYS_NO_PATHCONV=1`. * **Dukascopy: `bridge jar not found`, venue on paper.** The sidecar jar is a release asset. `crates/bridges/dukascopy/src/config.rs`'s `resolve_dukascopy_tools` looks at `JFOREX_BRIDGE_JAR` (blank is ignored), then `/bin/jforex/jforex-bridge.jar`; the JVM is `JAVA_HOME/bin/java` when that file exists, else `/bin/jre/*/bin/java`, else `java` from `PATH`. Fix: `crates/bridges/dukascopy/scripts/provision-jforex.sh` (`provision-jforex.ps1` on Windows), which supplies both the runtime and the jar, or point `JFOREX_BRIDGE_JAR` at any copy. An **instant** `login failed` afterwards is a corrupted platform cache, not a credential problem — delete `%LOCALAPPDATA%\JForex` and retry. ## Credentials and arming [#credentials-and-arming] "There are no settings here" and "the settings here say nothing" are indistinguishable downstream, so a daemon that resolved no project loads no policy, no config and no credentials and trades every venue on paper with no error — the incident `vike_boot`'s module header is made of. * **Every venue on paper; the log sits beside the executable; the store line says `rung=user-dir`.** No project marker above the working directory. The walk (`crates/vike-model/src/state_path.rs`'s `project_settings_dir`) takes the outermost `Cargo.toml` declaring `[workspace]`, else the nearest `Cargo.toml` or `settings/` directory; the probe is `is_dir`, so creating `/settings/` is the whole fix. `VIKE_SETTINGS_DIR` names it outright and beats the walk. * **`credential store could not be opened, continuing with NO credentials (every venue stays paper)`.** The store exists and is unreadable — an error, where an absent store is the ordinary silent state (`crates/vike-secrets/src/store.rs`'s `resolve`). Fix: the file's permissions; `vike-cli secrets path` prints the one location consulted. * **`… holds live venue credentials in PLAINTEXT and is readable beyond its owner (mode 0644)`.** A finding, not a refusal; the credentials load anyway. Fix: the `chmod 600` the line prints. * **`/.env is present but the credential store /settings/secrets.env is NOT`.** Nothing reads a project-root `.env` since credentials moved into the settings directory (`LegacyStoreWarning`). Fix: copy the venue keys into the store. * **"I have credentials and the venue is still paper."** The ceiling refused: `ARMING CEILING: has live credentials, and this deployment's ceiling for it is paper → staying PAPER` (`crates/vike-mount/src/paper_fallback.rs`'s `report_capped_to_paper`). Credentials and no `[venues]` table gets one `ARMING CEILING NOW IN FORCE` block per process listing every affected venue; writing the table at all silences it, and a ceiling can only refuse — `live` does not put a venue live. See [Risk and arming](/docs/trader/concepts/risk-and-arming) and [`policy.toml`](/docs/trader/reference/settings/policy). * **The Data Manager's Venues rows name a block.** A row is one venue *account* — a box with no `[accounts]` table gets exactly one per roster venue, the default account's — and its `ArmingBlock` names its own fix. `Disarmed` (the ceiling), `NoCredentials` (the Connections tab), `LiveCredentialsAbsent` (the store holds demo-tier keys only), `MainnetSwitchUnset` (a `live` ceiling still resolves demo without `{VENUE}_MAINNET`), `FeatureAbsent` (this binary has no arm for ibkr/polymarket/fxcm), `SdkAbsent` (FXCM, below), `ExecFlagUnset` (`POLY_EXEC=1`), `NoMountInThisBuild` (the desktop, which mounts no venue), `NoLiveArm` (dukascopy, whose exec factory is mounted in no build, so it can only ever be paper), `DemoOnlyArm` / `LiveOnlyArm` (the arm has no such tier — a `demo` ceiling over polymarket resolves paper), and, on a labelled second account, `AccountNotNamed` (no `policy.accounts..] ``` That usage string is `USAGE` in `crates/vike-cli/src/cmd/mcp.rs`. Its `parse_config` accepts those flags plus `-h`/`--help`, which prints the usage to stdout and exits 0; anything else exits 1 with `unknown argument`. The `--addr` default is that same file's `DEFAULT_ADDR`. | Flag | Default | What it reaches | | -------- | ---------------- | ----------------------------------------------------------------------------------------------- | | `--addr` | `127.0.0.1:7878` | the `vike-datahub` server the backtest, sweep, walk-forward and inventory tools run against | | `--node` | *none* | the running `vike-tradehub` node `node_snapshot` reads and the seven order-write tools write to | Both are optional and both only *widen* what the tools reach. With neither, the four offline authoring tools — `validate_strategy`, `discover_params`, `list_templates`, `list_indicators` — still work, which makes a flag-less entry a reasonable first step. `--node` has no default on purpose: without it, `node_snapshot` returns a clean error naming the flag before any socket is opened — `Server::ensure_observe` and `Server::ensure_control` in `crates/vike-cli/src/cmd/mcp.rs` check for the address before they connect. A write tool says nothing about it until much later: a call that does not carry **both** `confirm: true` **and** a valid `preview_token` returns a preview whatever the flag says, so the missing-node error appears only on a fully confirmed call. A preview is never evidence the flag arrived; a node snapshot is. The other two *narrow* it, and they are the half a client entry is the natural place for: | Flag | Default | What it decides | | ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `--profile` | `full` | which tools this server serves at all — `full` (17), `read-only` (10, no order-write tools) or `offline` (4, nothing that opens a socket) | | `--deny-tool` | *none* | one more tool withheld by name; repeatable | | `--trace` / `--trace-dir` | off | append one JSONL record per tool call under `/settings/state/agent`, or in a directory you name | Under `read-only` the seven order-write tools are **absent from `tools/list`**, not merely refused — `ToolAccess::new` produces the advertised set and its withheld complement in one pass, so the roster and the router cannot disagree. That makes a scoped entry the right shape for a client you want reading a live node and never trading it: ```json { "mcpServers": { "vike-trader": { "command": "vike-cli", "args": ["mcp", "--node", "127.0.0.1:7879", "--profile", "read-only"] } } } ``` An unknown profile name or deny name is a usage error naming the valid set; neither ever falls back to something wider, which matters most in a config file nobody re-reads. And because a scope lives in the launch command, only whoever edits that file can widen it — the agent cannot, and the refusal it gets says so rather than sending it hunting for a switch. A node listens on nothing until `tradehub_addr` in `config.toml` (or `VIKE_TRADEHUB_ADDR`) names an address — `start_observe_server` in `crates/vike-tradehub/src/tradehub_cli.rs` returns without binding when it is unset. `127.0.0.1:7879` is the conventional one (`crates/vike-tradehub/src/server.rs`'s `DEFAULT_ADDR`), and loopback is deliberate — reach a remote one through an ssh tunnel rather than by binding it publicly; [Remote node](/docs/ai/trader/setup/remote-node) is that recipe. `vike-cli mcp` authenticates to your node with an HMAC-SHA256 nonce challenge, and the scope byte is signed into the tag — so a key issued for reads cannot mint a write. That is the whole of what it buys. The handshake itself is **plaintext**, and it authenticates the **connection**, not the individual frames that follow it. Anyone who can read the traffic can read your orders, your positions and your symbols; anyone who can modify it in flight is not stopped by the handshake either. The primitive says so about itself, in a section headed *What this does NOT buy* — `crates/vike-datahub-client/src/node_auth.rs`, where `sign` and `verify` live, with `crates/vike-tradehub-client/src/auth.rs`'s `DOMAIN` binding it to this service. On loopback the party who can read that traffic is nobody but the machine's own users, which is why the daemon binds `127.0.0.1` and why these pages steer you away from binding it publicly. For a node on another host, an ssh tunnel (or a VPN) is not a convenience — it is the layer supplying the confidentiality and the in-flight integrity the protocol deliberately does not. [Remote node](/docs/ai/trader/setup/remote-node) is the tunnel recipe, including what the daemon does when someone binds `0.0.0.0` anyway and what a dropped tunnel looks like from the tools. ## The credentials [#the-credentials] No Vike account is involved, and nothing here is metered. `vike-cli mcp` talks to services **you** run, and the two node keys are HMAC secrets shared with your own `vike-tradehub` daemon: * `VIKE_TRADEHUB_OBSERVE_KEY` — reads (`node_snapshot`), * `VIKE_TRADEHUB_CONTROL_KEY` — writes (the seven order-write tools). Each is resolved independently, and in this order: **the process environment first, then the credential store the daemon itself reads** (`/settings/secrets.env`). The environment wins because it is the narrower, more deliberate source — that is what makes "point at a different node for one command" possible at all; `crates/vike-cli/src/cmd/nodekeys.rs` argues the precedence at length. Values are trimmed on both sides, because the daemon trims too (`from_vars` in `crates/vike-tradehub-client/src/auth.rs`) and a key with a trailing newline would otherwise fail the handshake as an opaque `AuthDenied`. A key present in neither place disables only the tools that need it. The server still starts, the offline and backtest tools still work, and the affected tool returns an error naming **both** locations (`Server::missing_key` in `crates/vike-cli/src/cmd/mcp.rs`). The two scopes are separate at the node as well as here: a control key cannot authenticate a read. 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. `vike-cli secrets path` prints which store this project resolves to; `vike-cli secrets list` shows whether a key is in it, by name — never values. ## Where the binary comes from [#where-the-binary-comes-from] Every client entry starts with a path to `vike-cli`, so it has to exist and be findable: * **A release download.** `vike-cli` is published as its own asset, alongside `vike-tradehub`, `backtest`, `vike-datahub`, `vike-recorder`, `vike-study`, `tearsheet` and `vike`, and covered by the release's `SHA256SUMS`, which is the upload manifest — an asset not named there is not published. * **The consolidated `vike` executable.** One multicall binary answers to every tool name — by `argv[0]` when it is installed under that name, and otherwise as a verb, so `vike vike-cli mcp` is the same server. `crates/vike/src/main.rs`'s `TOOLS` is the list of names it answers to, and `crates/vike/src/lib.rs`'s `resolve` is the two shapes. * **A checkout.** `cargo run -p vike-cli -- mcp` works, but a client entry wants a real path. The image's `ENTRYPOINT` is the daemon's entrypoint script (`deploy/docker/Dockerfile`), so reaching the CLI means overriding it and keeping stdin open (`-i`): ```sh docker run -i --rm -v /srv/vike-trader:/project \ --entrypoint /opt/vike/bin/vike-cli \ vikeio/vike-tradehub: mcp ``` `/opt/vike/bin/vike-cli` is a relative symlink to the multicall binary — the links are the image's interface, not a convenience. And note that `127.0.0.1` *inside* a container is the container's own loopback, so a `--node` pointing at a daemon elsewhere needs an address that container can actually reach. [Install with Docker](/docs/trader/getting-started/install-docker) covers the mount. ## Verify the connection [#verify-the-connection] One prompt, and it proves the whole path — the client launching the binary, the protocol, and tool dispatch — rather than just the config file being read: > list the vike strategy templates That calls `list_templates`, which is offline: no datahub, no node, no key, no network. If it answers, the wiring is correct and anything still failing is an address or a key rather than the setup. With a node configured, follow it with *"show me the node snapshot"* to prove the observe key too. If nothing happens, three checks in order: 1. **Is the server listed at all?** Every client has a way to show its configured MCP servers. If `vike-trader` is missing, the config file was not read — usually a restart, or the wrong file. 2. **Does the command run by hand?** `vike-cli mcp` in a terminal reads JSON-RPC on stdin, so a binary that is not on `PATH`, or a flag it rejects, surfaces immediately instead of as a silently absent server. [Raw JSON-RPC](/docs/ai/trader/setup/raw-http) has the one-line handshake. 3. **Did the tools load but not get used?** Some clients require you to enable a server's tools per conversation. Name the tool — "use the `list_templates` tool" — to force the issue. ## Removing it [#removing-it] There is nothing to revoke centrally, because there is no account: delete the entry from the client's config, or rotate the node keys in `/settings/secrets.env` and restart the daemon. A client holding the old key then fails the handshake. Once it is connected, [Trader MCP server](/docs/ai/trader) is the tool-by-tool reference — and the seven order-write tools have their own contract on [Live node](/docs/ai/trader/trading). # Raw JSON-RPC (/docs/ai/trader/setup/raw-http) ⚠ **The trader server is not HTTP.** It has no URL, no port and no headers: `vike-cli mcp` is MCP's **stdio** transport — newline-delimited JSON-RPC 2.0 on stdin and stdout, one message per line with no embedded newlines. Anything a client would send, a shell can send. (The hosted [data server](/docs/ai/data) *is* HTTP; its curl walkthrough is on [Data client setup → Raw HTTP](/docs/ai/data/setup/raw-http).) An MCP client handles all of this for you. You need it anyway when you are scripting against the server, or when a client will not connect and you want to know whether the fault is yours or its. ## The handshake [#the-handshake] ```sh printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-client","version":"1.0.0"}}}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ | vike-cli mcp ``` The server echoes the `protocolVersion` you asked for — falling back to `2024-11-05` when a client declares none (`DEFAULT_PROTOCOL` in `crates/vike-cli/src/cmd/mcp.rs`) — and names itself: ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-03-26", "capabilities": { "tools": {}, "resources": {}, "prompts": {} }, "serverInfo": { "name": "vike-cli", "version": "..." }, "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." } } ``` `serverInfo.version` is the version of the `vike-cli` binary you launched, so it tells you which build you are talking to. `capabilities` advertises `tools`, `resources` and `prompts`; there is no sampling to negotiate. `instructions` is free text a client shows the model as guidance about the server — elided above because it is a paragraph, not a field you parse. It exists because a tool description can only describe *its* tool: "there is no tool for this, and the command that does it is `vike-recorder`" is a fact about the **absence** of a tool, so there is nowhere in a roster to write it. So the text says this server is one surface of a larger system and names the operator-side commands for the rest — recording a tape, getting bars into the history store, a local backtest, and which credential keys this box holds — plus two rules: invent no tool name, and never report a command a human would have to type as something you ran. It is assembled by `instructions` in `crates/vike-cli/src/cmd/mcp.rs` from named constants, so the string is the same on every run of a given build and profile. ⚠ **It is scoped by the same `ToolAccess` the roster is, and it grants nothing.** A `read-only` session's text drops the preview-gate sentence and an `offline` one drops `list_series` too, because prose naming a tool `tools/list` withholds is advertising a withheld tool (`the_instructions_are_scoped_by_the_same_access_the_roster_is`). What the agent may *call* is unchanged either way — this field is advice, and every gate on this page still applies to the call that follows it. The operator-side commands are in every profile's text and must be: they are things a human types, and no scope of this server changes what the operator's terminal can run. **`initialize` is not a gate.** Each method is dispatched on its own, so the one-line version of "is this thing working" is a bare `tools/list`: ```sh printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ | vike-cli mcp | jq '.result.tools | length' ``` 17, under the default `--profile full`. `tools/list` takes no cursor and returns the registry (`tools_spec()`) filtered by whatever scope the server was launched with, every time — so a count of 10 or 4 means the operator passed `--profile read-only` or `--profile offline`, and a count one short of either means a `--deny-tool`. ## The nine methods [#the-nine-methods] | Method | Answer | | --------------------------- | -------------------------------------------------------------------------- | | `initialize` | the block above | | `notifications/initialized` | nothing at all — it carries no `id` | | `ping` | `{}` | | `tools/list` | `{"tools": [...]}` — every tool this server's scope admits | | `tools/call` | a tool result — see below | | `resources/list` | `{"resources": [...]}` — `vike://node/snapshot` and `vike://backtest/last` | | `resources/read` | `{"contents": [{uri, mimeType, text}]}`, an array even for one document | | `prompts/list` | `{"prompts": [...]}` — three flows, each with its arguments | | `prompts/get` | the rendered sheet as one `user` message | Anything else is a `-32601` `method not found: `. A **resource** is a read the agent does not have to choose a tool for, addressable by URI so a client can pin it into a conversation: each one routes into the matching tool's own implementation, so it can never answer differently from the tool beside it, and a URI whose tool the scope withholds is refused rather than served. `resources/read` on a resource that has nothing to serve — no backtest has run in this session — is a `-32002`, never an empty document, because `{}` is what an agent would summarise as "the backtest produced nothing". A **prompt** executes nothing, so the scope never withholds one; what changes is the text, which names only the write tools this session actually admits. Four framing rules follow from how the loop reads stdin *and* how each message is dispatched — `serve` and `Server::handle` in `crates/vike-cli/src/cmd/mcp.rs` — and a script author will hit all of them: * **A request carrying an `id` gets exactly one response line; a notification gets none.** A message with no `id` is answered with silence even for `initialize`. * **A line that parses as JSON but carries no `method` is ignored silently.** So echoing a response back into the pipe produces nothing, rather than an error you could catch. * **A line that is not JSON is a `-32700` with `id: null`, and the loop keeps going.** A malformed message does not kill the session. * **Blank lines are skipped, and EOF exits 0.** Closing stdin is the clean shutdown. Each response is written and **flushed immediately**, so a script that writes a request and then blocks on a read will not deadlock waiting for a buffer. The tool's JSON is pretty-printed — but *into a JSON string*, where the newlines become `\n` escapes. The transport framing is unaffected: one message, one line. Read by lines, then decode. ## The result envelope [#the-result-envelope] A successful tool call carries the payload **twice**: pretty-printed inside the usual `text` block, and again as `structuredContent` for a client that would rather not re-parse a string. ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "{\n \"templates\": [ ... ]\n}" }], "structuredContent": { "templates": [] }, "isError": false } } ``` A failure is a flagged result with **no** `structuredContent`: ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "discover_params requires a `script` string argument" }], "isError": true } } ``` An unrecognised tool name comes back as `unknown tool: ` in an ordinary result carrying `isError: true`. A script that only inspects the JSON-RPC `error` member will read a typo'd tool name as a successful call and then parse a missing payload. So will a tool that *panics* — a panic is deliberately caught and returned as `tool panicked: ` with `isError: true`, rather than being allowed to take the session down. **Test `result.isError` on every call**, not just the presence of `error`. ## Calling a tool [#calling-a-tool] ```json { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "validate_strategy", "arguments": { "script": "let x = param(\"len\", 20);" }}} ``` Note `validate_strategy` and `discover_params` take **`script`**, not `code`. The two share one compile path and disagree about a bad script: `validate_strategy` returns it as an `ok: false` *answer* — the agent reads the compiler message and fixes the script — while `discover_params` returns it as a tool error, `rhai compile error: …`. A missing `script` argument is a tool error for both. The four offline tools, and the argument each takes, are on [Authoring](/docs/ai/trader/authoring); the datahub-backed ones are on [Backtesting](/docs/ai/trader/backtesting). ## An order-write tool: the two calls, on the wire [#an-order-write-tool-the-two-calls-on-the-wire] Every write tool is preview-gated, and the gate has **two** conditions rather than one: it executes only when a call carries `confirm: true` *and* a `preview_token` this server issued for that exact command. A first call is therefore always a preview, and with no `--node` configured it opens no connection either — which is what makes the request below safe to paste anywhere. **Call one — the preview.** ```json { "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "submit_order", "arguments": { "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.001 }}} ``` The result's `structuredContent` is the preview. ⚠ **The values below are illustrative** — the minted id and the token counter differ on every run, and `node_verdict` is whatever *your* node says — but the field set is exactly what `preview_of` emits: ```json { "will_execute": false, "tool": "submit_order", "wire_command": { "Submit": { "client_order_id": "a1b2c3d47", "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.001, "order_type": "market", "price": null, "trigger_price": null, "reduce_only": false } }, "guardrail": { "qty": 0.001, "notional": null, "max_qty": null, "max_notional": null, "within_limits": true }, "node_verdict": null, "verified_by_node": false, "preview_token": "pv-1", "reason": null, "note": "PREVIEW ONLY — nothing was sent. ..." } ``` `wire_command` is `WireCommand` in `crates/vike-tradehub-client/src/wire.rs`, serialized as-is — externally tagged, so the variant name is the key and `WireOrderRequest`'s fields are the object. `notional` is `null` because a market order carries no price to size against, which is the default order type and the reason the client-side guardrail is not something to lean on. `node_verdict` is `null` here because this example has no `--node` and no control key, so there was nobody to ask; with a node configured it carries the node's own dry-run and `verified_by_node` becomes `true`. The `note` says the same thing in prose, and on this unverified path it says explicitly that the guardrail is a client-side estimate and not an approval. **Call two — the execution.** The same arguments, plus both fields: ```json { "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "submit_order", "arguments": { "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.001, "confirm": true, "preview_token": "pv-1" }}} ``` Read `preview_token` out of call one's response and put it in call two — that is the whole protocol. What reaches the venue is the command the *preview* resolved, id included, so there is nothing to copy back by hand. Every way of failing this gate returns a **preview**, not an error: no `preview_token`, or a `confirm` that is the string `"true"` rather than the JSON boolean (or `1`, or `"yes"`), or both. `isError` is `false`, `structuredContent` is a fresh preview carrying a **new** token, and nothing went out. Branch on `will_execute`, never on `isError` alone. The tokens that *do* raise a tool error are the ones you cannot use anyway: unknown or already used (a token fires at most once), expired (the window is 60 seconds), or bound to a different command (the binding compares the whole wire command). [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) has the four rules and the reason the token is deliberately not a secret. ### The four outcomes of a confirmed write [#the-four-outcomes-of-a-confirmed-write] The server waits for the acknowledgement of the exact command it sent, so the answer always describes *this* command. It waits up to 2 seconds (`ACK_WAIT`), and there are exactly four shapes: | What happened | Shape | | ---------------------------- | ----------------------------------------------------------------------------------------- | | The node accepted it | a normal result: `sent: true`, `outcome: "accepted"`, the authoritative `client_order_id` | | The node refused it | `isError: true`, text `node rejected the command: ` | | The connection dropped first | `isError: true`, text saying the outcome is **UNKNOWN** and it may have executed | | No answer within 2s | a **normal** result: `sent: true`, `outcome: "unknown"` | ⚠ **The last two both mean "it may have executed", and only one of them is flagged.** A timeout comes back as a success-shaped result whose `outcome` is the string `"unknown"`; a dropped connection comes back as `isError`. A script that branches on `isError` alone will treat a timed-out submit as a completed one. In both cases the obligation is the same and the note says so: call `node_snapshot` **before** retrying. The full contract — what the node's `ControlLimits` (`crates/vike-tradehub/src/server.rs`) and `RiskGate` (`crates/vike-exec/src/risk.rs`) actually enforce, and why the client-side guardrail is advisory — is on [Live node](/docs/ai/trader/trading). ## Keeping one process alive [#keeping-one-process-alive] The node connections are opened **lazily and kept** for the life of the process: a control command is fire-and-forget over a background worker, and the observe connection has to stay subscribed to keep receiving snapshot pushes (the `Server` struct's own doc says why a per-call connect would be wrong). So a `printf | vike-cli mcp` one-shot reconnects every time, which is fine for a health check and wasteful for a session that will issue several node calls. For a real scripted session, hold the pipe open: ```sh mkfifo /tmp/vike-mcp-in vike-cli mcp --node 127.0.0.1:7879 < /tmp/vike-mcp-in & exec 3> /tmp/vike-mcp-in echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' >&3 echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"node_snapshot","arguments":{}}}' >&3 exec 3>&- # EOF — the server exits 0 ``` ## Credentials, and their absence [#credentials-and-their-absence] There is no credential in any of the offline calls above: they need none, and nothing here is metered. The node tools need `VIKE_TRADEHUB_OBSERVE_KEY` (read) or `VIKE_TRADEHUB_CONTROL_KEY` (write), read from the process environment and then from `/settings/secrets.env`, and say so **by name and by location** when neither has one: ```text VIKE_TRADEHUB_CONTROL_KEY is set neither in the process environment nor in the credential store — 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 ``` A missing `--node` is a different message, and worth telling apart from the one above: ```text no vike-tradehub node configured — pass `--node ` to read node state ``` Both arrive before any socket is opened — `Server::ensure_observe` and `Server::ensure_control` check the address and the key before they connect — so neither costs a connection attempt. ## When the command itself is the problem [#when-the-command-itself-is-the-problem] Two exit codes are worth knowing, because `set -e` sees them: * `vike-cli mcp --help` prints the usage to **stdout** and exits **0**. Help is normal output, not a diagnostic — `exit_for_parse_error` in `crates/vike-cli/src/cmd/args.rs` is where that call is made, for every non-interactive subcommand. (It exited 1 once, and that broke every wrapper checking a status.) * An unrecognised flag prints `vike-cli mcp: unknown argument: ` plus the usage to **stderr** and exits **1**. Everything `vike-cli` has to say goes to stderr, deliberately: under `mcp`, stdout is a protocol. ## In practice, you ask in English [#in-practice-you-ask-in-english] Once a client is connected you write none of the above. The mapping is what the tool descriptions are for: | You ask | The client calls | | --------------------------------------------- | ------------------------------------------------ | | *"Show me the strategy templates"* | [`list_templates`](/docs/ai/trader/authoring) | | *"Does this strategy compile?"* | [`validate_strategy`](/docs/ai/trader/authoring) | | *"What knobs does it have?"* | [`discover_params`](/docs/ai/trader/authoring) | | *"Which indicators can it call?"* | [`list_indicators`](/docs/ai/trader/authoring) | | *"Backtest this on BTC through last quarter"* | [`run_backtest`](/docs/ai/trader/backtesting) | | *"Sweep the fast/slow lengths"* | [`run_sweep`](/docs/ai/trader/backtesting) | | *"Validate it walk-forward"* | [`run_walk_forward`](/docs/ai/trader/backtesting) | | *"What data do we have stored?"* | [`list_series`](/docs/ai/trader/backtesting) | | *"What is the node holding right now?"* | [`node_snapshot`](/docs/ai/trader/trading) | | *"Flatten the BTC position"* | [`flatten`](/docs/ai/trader/trading), previewed first | # Remote node (/docs/ai/trader/setup/remote-node) A node on another machine is reached exactly the way a local one is — `--node 127.0.0.1:7879` — with one thing added in front: an ssh tunnel that makes the daemon's loopback port appear on yours. Nothing on the daemon changes. This page is that recipe, and the reason it is the recipe rather than one option among several. ## Why a tunnel, not an open port [#why-a-tunnel-not-an-open-port] The node protocol authenticates the **connection** and encrypts **nothing**. Its own primitive says so, in a section headed *What this does NOT buy* — `crates/vike-datahub-client/src/node_auth.rs`, where `sign` and `verify` live: the handshake is plaintext and authenticates the connection rather than each frame, and confidentiality and integrity "still come from the tunnel (or a VPN) in front of it" — scoped authorization behind that barrier, never a replacement for it. The daemon's own account of the consequence is blunter (`start_observe_server` in `crates/vike-tradehub/src/tradehub_cli.rs`): on a reachable network the handshake hands out an offline cracking target for the node key, and once a session is up an on-path attacker can inject a `Command`. So the tunnel supplies the two properties the wire deliberately lacks; the keys supply only the third — *which* of the two scopes this peer may use. ## What the daemon binds, and what not to change [#what-the-daemon-binds-and-what-not-to-change] `crates/vike-tradehub/src/server.rs`'s `DEFAULT_ADDR` is `127.0.0.1:7879`, and its doc comment names the tunnel as the intended way in. The address the daemon actually uses is `tradehub_addr` in `config.toml` (`Config::tradehub_addr`, `crates/vike-config/src/config.rs`), which `VIKE_TRADEHUB_ADDR` (`TRADEHUB_ADDR_ENV` in the same file) still overrides — and it is **unset by default**, meaning no node server at all: `start_observe_server` returns without binding when the address is absent or blank. On the daemon's machine, then: ```toml # /settings/config.toml tradehub_addr = "127.0.0.1:7879" ``` Keep it loopback. The tempting edit — `0.0.0.0:7879`, "so I can reach it" — is the thing this page exists to steer you away from, and the daemon now refuses it: `bind_decision` in `server.rs` classifies the wildcard binds and any non-loopback interface as public (`bind_exposure`, which also calls a hostname resolving to both loopback and a LAN address public — the strictest answer is the true one), and without the separate opt-in `flags.tradehub_allow_public_bind` (or `VIKE_TRADEHUB_ALLOW_PUBLIC_BIND=1`) the observe server is **not started** and the daemon keeps trading headless. The refusal is logged as an error whose text tells you to keep `tradehub_addr` on `127.0.0.1` and run the tunnel below. The flag exists for a trusted LAN or a VPN interface; it is a distinct flag rather than an inference from the address because typing the address *is* the mistake. The daemon reads both node keys from **its own** credential store at bind time (`vike_tradehub_client::auth::from_vars` over the daemon's `/settings/secrets.env`), and without `VIKE_TRADEHUB_OBSERVE_KEY` there it likewise starts no server. Control is gated once more by `flags.tradehub_control`. ## The tunnel [#the-tunnel] ```sh ssh -L 7879:localhost:7879 ``` Read `-L` left to right: `7879` is the port opened on **your** machine; `localhost:7879` is where the remote sshd connects on your behalf, evaluated **on the remote host** — so `localhost` there is the daemon's loopback, which is exactly why a loopback bind is enough. Take the port from `DEFAULT_ADDR` or from whatever `tradehub_addr` you set, not from memory. That form ties the tunnel to an interactive shell. For a session that outlives a terminal: ```sh ssh -N -f \ -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ -o ExitOnForwardFailure=yes \ -L 7879:localhost:7879 ``` `-N` runs no remote command, `-f` backgrounds after authenticating, the two `ServerAlive*` options make a silently dead link fail within about 90 seconds instead of hanging, and `ExitOnForwardFailure` turns "the local port was already taken" into a non-zero exit rather than a tunnel that forwards nothing. To have something re-establish it, hand the same line to `autossh`: ```sh autossh -M 0 -N \ -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes \ -L 7879:localhost:7879 ``` or give it to a systemd user unit, which restarts it for you: ```ini # ~/.config/systemd/user/vike-node-tunnel.service [Unit] Description=ssh tunnel to the vike-tradehub node [Service] ExecStart=/usr/bin/ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ -o ExitOnForwardFailure=yes -L 7879:localhost:7879 Restart=always RestartSec=5 [Install] WantedBy=default.target ``` (`systemctl --user enable --now vike-node-tunnel`.) If the backtest tools' datahub lives on the same host, forward its port in the same command — `-L 7878:localhost:7878`, the `DEFAULT_DATAHUB_ADDR` in `crates/vike-config/src/config.rs` — and `--addr` then needs no change either; that file's `Config::datahub_advertise_addr` doc describes precisely this two-port tunnel posture. ## Pointing the client at the local end [#pointing-the-client-at-the-local-end] ```sh vike-cli mcp --node 127.0.0.1:7879 ``` The address is your own loopback, so every client entry on the [setup pages](/docs/ai/trader/setup) works unchanged. The same local end serves the human surfaces: `vike-cli trade --node 127.0.0.1:7879` (`USAGE` in `crates/vike-cli/src/cmd/trade.rs`) and `vike-desktop --observe 127.0.0.1:7879` — the argument after `--observe` is the address (the scan in `crates/vike-desktop/src/main.rs`'s `main`); [Observe mode](/docs/trader/gui/observe-mode) is that page. Two questions are being answered here, and they have different answers. **The tunnel authenticates you to the host** — your ssh key, sshd's business. **The node keys authenticate the connection to the node** — `VIKE_TRADEHUB_OBSERVE_KEY` for reads and `VIKE_TRADEHUB_CONTROL_KEY` for writes (`OBSERVE_KEY_ENV` / `CONTROL_KEY_ENV` in `crates/vike-cli/src/cmd/nodekeys.rs`), resolved by `resolve` in that file per key and independently: the **process environment first, the credential store second**, a value blank after trimming counting as absent in both, so an empty export cannot shadow a stored key. They must be the **same** values the daemon loaded on its machine, and on a remote setup the store your client resolves is *your* `/settings/secrets.env`, not the daemon's — so either put the two values in it or export them. A key missing from both places returns a tool error naming both (`Server::missing_key`, `crates/vike-cli/src/cmd/mcp.rs`); the `trade` REPL prints which source answered on its `connected:` line (`nodekeys.rs`'s `KeyOrigin`). Withholding the control key is one way to give an agent a read-only view of a remote node, and it has a limit worth knowing: the write tools are still advertised, and a fully confirmed call reaches the missing-key error rather than being refused up front. `--profile read-only` is the other way, and it acts one layer earlier — the seven order-write tools are absent from `tools/list` and refused by `tools/call`, from one derivation, so nothing on that side of the tunnel offers the agent an order to place: ```sh vike-cli mcp --node 127.0.0.1:7879 --profile read-only ``` The two compose, and neither can be widened from the agent's side of the pipe. `--deny-tool NAME` takes out one more tool by name. ## When the tunnel drops [#when-the-tunnel-drops] The observe and control connections are opened lazily and kept for the life of the `vike-cli mcp` process (`Server` in `crates/vike-cli/src/cmd/mcp.rs`) — but a dead one is **not** kept: the next call that finds it dead reopens it. Four shapes, depending on when the tunnel went and whether the socket noticed: * **Before the first use.** `Server::ensure_control` returns `cannot open control connection to 127.0.0.1:7879: ` — usually a refused connection, because nothing is listening on your local port; `Server::ensure_observe` says the same with `observe`. Nothing was sent. * **Mid-command.** The write tool returns an `isError` whose text says the control connection dropped before the node answered and the outcome is **unknown** — it may have executed. That is `CommandOutcome::Disconnected` in `crates/vike-tradehub-client/src/remote_control.rs`, and the [Outcomes](/docs/ai/trader/trading#outcomes) table on the trading page is the contract for it. The handle is dropped, so the *next* explicit call reconnects — the unknown command itself is never resent by anything. * **While idle, and the socket reported it** (the tunnel process exited, the daemon restarted). The control worker probes the link before writing (`link_is_dead` in `remote_control.rs`), so a command that reaches a dead link is classified `CommandOutcome::NeverSent` — not one byte went on the wire — and `Server::execute` reconnects and sends *that* command on the same call. The agent sees an ordinary outcome, not "unknown". Reads are protected the same way: `tool_node_snapshot` checks `RemoteCoreHandle::is_connected` after waiting for a frame, tries one fresh connection, and otherwise returns an error naming the last frame's `seq` as **stale** — it never returns a stale frame as a result. * **While idle, and the socket did not report it** (laptop sleep, a Wi-Fi change, a VPN re-key under an `ssh -L` with no keepalive). The observe stream carries a heartbeat the node sends every `OBSERVE_HEARTBEAT` of silence, and the client reads it under `OBSERVE_READ_TIMEOUT` (three beats) — both in `crates/vike-tradehub-client/src/liveness.rs` — so a silently dead link is noticed within that window rather than never; a write's reply read carries its own deadline. From there it is the reported case above. To shorten the window at the source, run the tunnel with `ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -L …`, which turns a silent death into a reported one. The node no longer closes an idle authenticated connection: the 300-second bound in `crates/vike-tradehub/src/server.rs`'s `handle_connection` applies to the handshake of an *unauthenticated* peer and is replaced at `AuthOk` by `AUTHED_IDLE_TIMEOUT` (none) — an agent that thinks for ten minutes between calls is not disconnected for it. So recovery is: re-establish the tunnel, then carry on — the next call reconnects. No restart of `vike-cli mcp` is needed. The one discipline that remains is the same as on loopback: after any outcome that read **unknown**, call `node_snapshot` and read a fresh `seq` before retrying. ## Docker [#docker] The tunnel's local end is on the **host**, and `127.0.0.1` inside a container is the container's own loopback — the callout under [Where the binary comes from](/docs/ai/trader/setup#where-the-binary-comes-from) on the setup page covers what a containerised `vike-cli` needs instead. # VS Code (/docs/ai/trader/setup/vs-code) ## Add the server [#add-the-server] Create `.vscode/mcp.json` in the workspace and reload the window: ```json { "servers": { "vike-trader": { "type": "stdio", "command": "vike-cli", "args": ["mcp", "--node", "127.0.0.1:7879"] } } } ``` Two details are easy to miss if you are copying a Cursor entry: the top-level key is `servers`, not `mcpServers`, and each entry carries an explicit `type`. That `type` selects the rest of the shape — `stdio` takes `command` and `args`, `http` takes `url` and `headers`. `vike-cli mcp` is a local process VS Code launches and speaks stdio to, so `stdio` is the only correct value here. Both `vike-cli mcp` flags are optional: `--node ` is the running `vike-tradehub` the live tools observe and control (no default), `--addr ` the `vike-datahub` the backtest tools use (default `127.0.0.1:7878`). Without either, the four offline authoring tools still work. If VS Code cannot find the binary, give `command` an absolute path — an editor started from the desktop does not inherit your shell's `PATH`. ## Keep the node keys out of the repository [#keep-the-node-keys-out-of-the-repository] `.vscode/mcp.json` sits inside the workspace, so anything written into it is one `git add` away from being committed. The good news is that this server needs nothing secret in the file at all. `vike-cli` reads `VIKE_TRADEHUB_OBSERVE_KEY` and `VIKE_TRADEHUB_CONTROL_KEY` from the process environment first and then from `/settings/secrets.env` — the same store the `vike-tradehub` daemon reads (`crates/vike-cli/src/cmd/nodekeys.rs`'s `resolve`). A desktop-launched editor passes on none of your terminal's exports, so the store is the source that actually reaches the server, and it is also the source that keeps the workspace file clean. ⚠ A committed **control** key is worse than a committed API key: it places orders on a live node, and there is no central console to revoke it from. The fix is to rotate the key in `/settings/secrets.env` and restart the daemon, which is a real interruption — so do not put it here in the first place. ## Verify [#verify] Open Copilot Chat in agent mode and ask: > list the vike strategy templates `list_templates` runs offline, with no key, no node and no network, so it separates "is the subprocess running" from every credential question. Copilot's tools picker should list the `vike-trader` server; if the server is present but its tools are not offered, they may need enabling for the conversation. With a node configured, *"show me the node snapshot"* then proves the observe key. ## Troubleshooting [#troubleshooting] **The file was edited but nothing changed.** Reload the window. MCP configuration is read at startup. **The server never appears.** Check the file path (`.vscode/mcp.json`, in the workspace root VS Code actually has open) and that the JSON parses — a trailing comma silently disables the whole file. **The server starts but the live tools error while the offline ones work.** That is a node address or a node key, not the MCP wiring, and the two are worded to be told apart: "no vike-tradehub node configured" is the `--node` flag; "set neither in the process environment nor in the credential store" names a key and both places it looked. `vike-cli secrets path` prints the store this project resolves to; `vike-cli secrets list` shows whether the key is in it, by name. **The binary is not found.** Run `vike-cli mcp` in a terminal first. It reads JSON-RPC on stdin and writes every diagnostic to stderr — never stdout, because stdout is a protocol — so a `PATH` problem or a rejected flag surfaces immediately. ## The data server too [#the-data-server-too] The same file holds both Vike servers: the hosted [data server](/docs/ai/data) goes in beside this one as a `"type": "http"` entry with a key header. [Data client setup → VS Code](/docs/ai/data/setup/vs-code) has its shape, and the warning above about committing secrets applies with full force to that one — it *does* need a key in the file unless you use VS Code's input-variable support. The names must differ; a second `vike` replaces the first. # Windsurf (/docs/ai/trader/setup/windsurf) ## Add the server [#add-the-server] Open Cascade, click the **MCP** icon in the panel's top-right menu (or go to **Settings → Cascade → MCP Servers**), and edit the raw configuration. The file is `~/.codeium/windsurf/mcp_config.json`: ```json { "mcpServers": { "vike-trader": { "command": "vike-cli", "args": ["mcp", "--node", "127.0.0.1:7879"] } } } ``` `command` + `args` is the **local** shape, and it is the only one that works here: `vike-cli mcp` is a subprocess Windsurf starts and speaks newline-delimited JSON-RPC 2.0 to, and it has no URL at all. The remote shape — a URL rather than a command — is what the [data server](/docs/ai/data) uses. After editing, refresh the server list from that same MCP panel — Windsurf does not pick the file up on its own. ## Scoping what the tools reach [#scoping-what-the-tools-reach] Both `vike-cli mcp` flags are optional and both only *widen* what the tools reach. `--node ` is the running `vike-tradehub` the live tools observe and control — no default. `--addr ` is the `vike-datahub` the backtest tools ship profiles to, defaulting to `127.0.0.1:7878`. With neither, the offline tools — `validate_strategy`, `discover_params`, `list_templates`, `list_indicators` — still work, which makes a flag-less entry a reasonable first step: ```json { "mcpServers": { "vike-trader": { "command": "vike-cli", "args": ["mcp"] } } } ``` If Windsurf reports the binary as not found, give `command` an absolute path: an editor launched from the desktop does not inherit the `PATH` your shell exports. ## The node keys [#the-node-keys] This is the case the credential store exists for. Windsurf starts `vike-cli mcp` 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 — the server starts and the offline tools work, but `node_snapshot` reports a missing key immediately. A write tool is quieter about it: its preview still returns, with `node_verdict: null` and `verified_by_node: false`, because the node dry-run needs the control key and silently skips itself without one. The named key error appears only once a call carries both `confirm: true` and a valid `preview_token` and the send is actually attempted. Put both keys in `/settings/secrets.env`, the same store the `vike-tradehub` daemon reads, and one copy serves both halves. Each key resolves from the process environment first and then from that store, independently — `crates/vike-cli/src/cmd/nodekeys.rs`'s `resolve` is the whole rule. The entry does take an `env` block if you want to pin one: ```json { "mcpServers": { "vike-trader": { "command": "vike-cli", "args": ["mcp", "--node", "127.0.0.1:7879"], "env": { "VIKE_TRADEHUB_OBSERVE_KEY": "..." } } } } ``` An **observe** key in that file grants reads. A **control** key grants orders, so it is the expensive one to leave in a config file — prefer the store for it. `vike-cli secrets path` prints which store a directory resolves to; `vike-cli secrets list` shows whether a key is in it, by name — never values. ## Verify [#verify] > list the vike strategy templates `list_templates` is offline: no key, no node, no network. If it answers, the subprocess is running and the protocol is working, and anything still failing is an address or a key rather than the setup. With a node configured, *"show me the node snapshot"* proves the observe key too. ## Troubleshooting [#troubleshooting] **The server shows as failed.** Almost always the JSON: a trailing comma, or a key nested in the wrong object. `command`, `args` and `env` are siblings *inside* the server's own object. **It is green but the tool list is empty.** Refresh from the MCP panel. Windsurf reads the file on refresh, not on save. **The offline tools work and the node tools do not.** That is an address or a key, not the MCP wiring — and almost certainly the inherited-environment trap above. The two failures are worded differently on purpose: "no vike-tradehub node configured" means `--node` never arrived; "set neither in the process environment nor in the credential store" names a key, and names both places it looked. **A write tool answered and no order exists.** Not a Windsurf problem. A write executes only when a call carries **both** `confirm: true` **and** the `preview_token` its own preview returned; anything less comes back as another preview, with `will_execute: false` and no error. [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) is the contract, and it is worth reading before an agent in an editor is pointed at a live node. ## Where this shape came from [#where-this-shape-came-from] The config path and the `mcpServers` object with `command`/`args`/`env`, and the MCP panel route to it, were read from Windsurf's own Cascade MCP documentation in September 2026, not from memory — note that `docs.windsurf.com` now redirects into the Devin documentation, which is where that page lives. That page is the authority if the shape has moved; this one is the copy. ## The data server too [#the-data-server-too] Windsurf holds both Vike servers in the same `mcpServers` object — the hosted [data server](/docs/ai/data) as a remote entry beside this one. [Data client setup](/docs/ai/data/setup) has its shape. The names must differ, or the second replaces the first. # cancel_order (/docs/ai/trader/tools/cancel_order) `cancel_order` withdraws exactly one resting order, named by its client order id. It is a write tool, so it is annotated `destructiveHint: true` and previews unless a call carries **both** `confirm: true` **and** the `preview_token` its own preview returned, and it needs `vike-cli mcp --node ` plus `VIKE_TRADEHUB_CONTROL_KEY`. ## Arguments [#arguments] | Parameter | Type | Required | Notes | | ----------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- | | `client_order_id` | string | yes | The id to cancel | | `reason` | string | no | Rationale for 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 | Omitting the id is a tool error from `verbs::verb_from_tool_args` before any command is built. ## The preview [#the-preview] Call it without `confirm` and you get `preview_of(...)`: `will_execute: false`, the resolved `wire_command`, the advisory `guardrail`, the node's own dry-run when there is a node to ask, the echoed `reason`, the confirm note, and a **`preview_token`**. Nothing is sent. With no node configured nothing is opened either. To execute, call again with the same arguments plus **both** `confirm: true` **and** that token. Either one on its own returns another preview — not an error — so read `will_execute` rather than assuming the second call landed. The token fires once, expires after 60 seconds and is bound to this exact cancel; [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) has the four rules and the argument for why it is not a secret. The guardrail on this verb is always `Guardrail::NoSize` — rendered as `within_limits: true` with a note saying there was no order size to check. That is not a pass; it is the honest answer that a cancel has nothing to size. The rationale round-trip is pinned by `a_write_preview_echoes_the_reason_without_changing_the_command`, which uses this tool: a `reason` of `" stale quote after the feed gap "` comes back trimmed, a blank one comes back `null`, and the `wire_command` is byte-identical with and without it. The reason rides *beside* the command on the wire and can never alter it. ## What the node does with it [#what-the-node-does-with-it] `lower_command` maps `WireCommand::Cancel` straight to the core's `OrderIntent::Cancel`, and this is one of the order-scoped verbs, so the acknowledgement echoes the `client_order_id` you named rather than an empty string. `ControlLimits::vet` consumes a rate token, as it does for every command, and then stops: its doc is explicit that the notional cap applies only to order-increasing verbs carrying a price, and that cancel, flatten, mass-cancel, market-exit and trading-state are risk-reducing and never size-capped. Cancelling is never refused for being too large. A cancel is fire-and-forget, and the code says so plainly: `verbs::coid_charset_warning`'s argument closes with "cancel stays fire-and-forget, which is honest: the node cannot confirm that an order EXISTS either way." An id that never existed is acknowledged exactly like one that did. Call [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) to see whether the order actually left the book. ## A malformed id is sent anyway, and that is deliberate [#a-malformed-id-is-sent-anyway-and-that-is-deliberate] The human `trade` REPL refuses a `submit --coid` outside `^[A-Za-z0-9]{1,32}$` and only **warns** on a cancel naming one. This MCP surface does not warn at all — `coid_charset_warning` is wired into the REPL alone. The asymmetry is argued rather than accidental. A submit is *minting* an id, so insisting on a charset every venue accepts costs one retype. A cancel names an id that **already exists at the node**, and this client is not the only thing that puts orders there: [`submit_order`](/docs/ai/trader/tools/submit_order) passes an agent-supplied `client_order_id` through unvalidated, and `lower_command` requires only that an id be non-empty. So an order with a coid outside that charset can exist — and refusing to cancel it would make a live order uncancellable from the console, to enforce a client-side convention. That trade is unacceptable in a tool whose reason to exist includes flattening something in a hurry. ## Outcomes [#outcomes] `Server::execute` awaits this command's own ticket for `ACK_WAIT`, two seconds. `accepted` returns `sent: true` with the echoed id; a refusal is an error result carrying the node's reason; a timeout returns `outcome: "unknown"`; a dropped control connection is an error result saying the outcome is unknown and it may have executed. Both ambiguous cases end the same way — check [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) before retrying. To clear a whole book instead of one order, use [`mass_cancel`](/docs/ai/trader/tools/mass_cancel); to change an order rather than withdraw it, use [`modify`](/docs/ai/trader/tools/modify). The states an order passes through on its way out are described in [Order lifecycle](/docs/trader/concepts/order-lifecycle). > Cancel order a1b2c3d47 — it is stale after the feed gap. # discover_params (/docs/ai/trader/tools/discover_params) `discover_params` compiles a Rhai strategy and tells you which knobs it exposes: the exact keys a profile's `[strategy.params]` or a `[sweep]` grid may override, in declaration order. It is how an agent finds those names without asking the human who wrote the script. **Call it on a script you wrote, too.** The description used to read as an inspection tool for somebody else's code, and an agent that had just authored a strategy answered "which parameters can I tune" from memory of what it had written instead of calling this. It happened to be right and had no way to know it was: [`validate_strategy`](/docs/ai/trader/tools/validate_strategy) answers `{ok: true}` and says nothing about params, and this tool runs the top level only — so the one mistake worth catching, a `param()` reached from inside a hook, is invisible to the author as well as to the reader. The tool's own text now says both things (`tools_spec` in `crates/vike-cli/src/cmd/mcp.rs`). Offline. `tools_spec` annotates it `readOnlyHint: true`, `idempotentHint: true` and `openWorldHint: false` — the only one of the three that is unusual is the last: this tool touches no datahub and no node, so a session with neither configured can still call it. ## Arguments [#arguments] | Parameter | Type | Required | Notes | | --------- | ------ | :------: | -------------------- | | `script` | string | yes | Rhai strategy source | A missing or non-string `script` is a tool error before anything is compiled — the message names the argument it wanted. ## What comes back [#what-comes-back] ```json { "params": [ { "name": "fast", "default": 5.0 }, { "name": "slow", "default": 20.0 } ] } ``` `tool_discover_params` calls `vike_script::discover_params` and maps each `(name, default)` pair into that object. Defaults are always numbers — `ScriptCtx::params_seen` is an `IndexMap`, so a knob is an `f64` and nothing else. Order is **declaration order**, not alphabetical. `discover_params`'s own doc says "in declaration order", and the `IndexMap` behind `params_seen` is what makes that true rather than incidental. ## How the knobs are found [#how-the-knobs-are-found] `vike_script::discover_params` builds the engine, compiles the source, and runs its **top level exactly once** with an empty scope and no overrides. It is broker-agnostic: no `Strategy` is mounted, so nothing trades and nothing needs a venue. The recording happens inside the host function itself. `build_engine` registers `param(name, default)` to record the pair into `params_seen` — **first seen wins** — and return the injected override if one is present, else the default. Discovery injects no overrides, so what comes back is the declared default in every case. The registration comment is explicit that the default argument is a `rhai::Dynamic`, not an `f64`, and says why: registered as `f64` it accepted `param("fast", 5.0)` and **rejected** `param("fast", 5)` with a Rhai "Function not found" naming an `i64` — a knob that worked or did not depending on whether the author typed a decimal point. Every shipped template happens to write `5.0`, which is why it went unnoticed. Because only the top level runs, only the `param()` calls the top level reaches are recorded. That is the shape the engine documents for the call — `let fast = param("fast", 5.0);` at the top level, so the one-time run bakes the value in — and a knob buried inside a hook is invisible to discovery. That is not only a reporting gap: a `[sweep]` grid built from what this tool answers can never name that knob, so it is driven only by somebody who already knows it is there. The override map itself is not the limit — `crates/vike-script/src/engine.rs` registers `param` to read `overrides` on every call, so a name you *do* type into the grid reaches a hook-level `param()` like any other. ## A compile error is a tool error here [#a-compile-error-is-a-tool-error-here] This is the one place `discover_params` and [`validate_strategy`](/docs/ai/trader/tools/validate_strategy) differ, and they run the *same* compile path. `validate_strategy` turns a bad script into an `ok: false` **answer** the agent reads and fixes; `discover_params` returns a tool error, `rhai compile error: …`. If you are checking whether a script is sound, call `validate_strategy`; call this one when you want the knobs. Rhai resolves a registered function when its line **runs**, not at compile time, and this tool runs only the top level — so every call inside `on_bar` is unchecked. Three mistakes land in that blind spot: an unbound **name** (a typo, or any name outside the host-bound set), a wrong **arity**, and a wrong **argument type** on a concretely-typed binding — Rhai's `resolve_fn` hashes each argument's `TypeId` and performs no int-to-float coercion for a registered function, so `market(1, 1)` misses as hard as a typo, while the indicators and `param` take a `rhai::Dynamic` and accept either. Each raises a runtime error on **every bar** — function-not-found for the first two, and `crates/vike-script/src/engine.rs`'s `bad_arg` message for a non-numeric indicator argument. `RhaiStrategy`'s hook runner swallows it (fail-safe: zero orders that bar) and self-disables after 10 consecutive errors. The strategy looks mounted and silently never trades. Check the names against [`list_indicators`](/docs/ai/trader/tools/list_indicators), which reports the set the host actually binds. ## Where it sits in a session [#where-it-sits-in-a-session] Start from [`list_templates`](/docs/ai/trader/tools/list_templates) or your own source, call this to see the knobs, then name those knobs in a `[sweep]` table and hand the profile to [`run_sweep`](/docs/ai/trader/tools/run_sweep) — each key there overrides `strategy.params.` across a value grid. The language itself is documented under [Scripting](/docs/trader/scripting/writing-a-strategy), and the engine's registered surface in [The engine](/docs/trader/scripting/the-engine). > Here is my Rhai strategy — what knobs does it expose, and what are their defaults? # flatten (/docs/ai/trader/tools/flatten) `flatten` closes the net position in one `(venue, symbol)` book. It mints no size of its own: the quantity is the position, resolved when the intent is applied. Annotated `destructiveHint: true` like every write tool, it previews unless a call carries **both** `confirm: true` **and** the `preview_token` its own preview returned, and needs `vike-cli mcp --node ` plus `VIKE_TRADEHUB_CONTROL_KEY`. ## Arguments [#arguments] | Parameter | Type | Required | Notes | | --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- | | `venue` | string | yes | Venue id | | `symbol` | string | yes | Instrument symbol | | `reason` | string | no | Rationale for 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 | There is no quantity argument, and that is the design rather than an omission. `OrderIntent::Flatten` closes the `(venue, symbol)` net position with a **reduce-only market order for `|position|`, resolved at apply time**, and is a **no-op when flat**. If you want to take off part of a position, that is a reduce-only [`submit_order`](/docs/ai/trader/tools/submit_order), not this tool. ## The preview [#the-preview] Called without `confirm`, the write arm builds the command and returns `preview_of(...)` — `will_execute: false`, the resolved `wire_command`, the advisory `guardrail`, the node's own dry-run when there is a node to ask, the echoed `reason`, the confirm note, and a **`preview_token`**. Nothing is sent, and with no node configured nothing is opened. The guardrail is `Guardrail::NoSize` (`within_limits: true`, "no order size to check for this verb"): the client cannot size a flatten either, because the position it will close lives on the node. Closing the position takes a second call carrying **both** `confirm: true` **and** that token. Either one on its own returns a fresh preview instead — not an error — so `will_execute` is the field that tells you whether the flatten was actually issued. The token fires once, expires after 60 seconds and is bound to this `(venue, symbol)`; [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) has the four rules. Because the quantity is resolved at apply time on the node, the `wire_command` in the preview names only the venue and symbol. Nothing on this side can tell you how big the resulting market order will be. [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) is what shows you the position before you confirm — and the fill afterwards. ## What the node does with it [#what-the-node-does-with-it] `lower_command` maps `WireCommand::Flatten` to the core's `OrderIntent::Flatten` with an **empty** coid. That is the account-wide-verb convention: the acknowledgement has no order id to echo, because the order does not exist yet. The accepted response says so in its own note — an empty `client_order_id` means an account-wide verb. `ControlLimits::vet` consumes a rate token and stops there. Its doc lists flatten among the risk-reducing verbs — cancel, flatten, mass-cancel, market-exit, trading-state — that are never size-capped. A flatten cannot be refused for being too large, which is the right way round for the only verb whose whole job is getting smaller. ## It works from a halted core [#it-works-from-a-halted-core] The reduce-only shape is what gets flatten past the kill switch when nothing else gets through. `OrderIntent::MarketExit` documents the rule for the compound verb built out of this one: the `RiskGate` kill switch admits a **position-covered reduce** under `Halted` (`vike_model::is_covered_reduce`), and that is exactly the shape a flatten mints — side opposite the position, quantity `|position|`. The same property makes it permitted under `TradingState::Reducing`. Admission at that arm is all it buys: the rest of `RiskGate::check_inner` still runs, so an armed `max_notional_per_order` or `max_total_exposure` denies an oversized flatten in any state, and under `Halted` the verdict is re-taken on the **lot-rounded** size — a `|position|` of `1.8` on a `1.0` lot rounds up to `2.0`, which would flip the position, so that leg comes back denied with the reason `halted`. That correction was made because the reverse had been true for the verb's whole life: the gate denied every order under `Halted`, `reduce_only` included, so the exits were disarmed in precisely the situations that reach `Halted` on their own. A kill switch must stop you opening risk, never trap you in it. [Risk and arming](/docs/trader/concepts/risk-and-arming) sets out the order the gates are consulted in, and [`set_trading_state`](/docs/ai/trader/tools/set_trading_state) is the tool that moves the switch. ## Outcomes [#outcomes] `Server::execute` awaits this command's own ticket for two seconds. `accepted` returns `sent: true` with an empty `client_order_id`; a refusal is an error result carrying the node's reason; a timeout returns `outcome: "unknown"`; a dropped control connection is an error result saying the outcome is unknown and it may have executed. For both ambiguous cases, call [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) **before** retrying — a repeated flatten against a book that is already flat is a no-op, but a repeat issued while the first is still in flight is not obviously one. To empty one venue or the whole account rather than one book, use [`mass_cancel`](/docs/ai/trader/tools/mass_cancel) for the resting orders and [`market_exit`](/docs/ai/trader/tools/market_exit) for both at once. > Flatten the BTCUSDT position on binance. # MCP tools (/docs/ai/trader/tools) `tools/list` returns the whole registry in one response. `tools_spec()` in `crates/vike-cli/src/cmd/mcp.rs` is that registry — every tool's name, description, input schema and risk annotations — and `Server::call_tool`'s `match name` is the dispatch half. Each tool has its own page below: what it does, its schema, a call you can paste into a stdio session, the shape that comes back, and one prompt that reaches it. ## Three planes, and what each one needs [#three-planes-and-what-each-one-needs] The tools split by what they touch, not by what they do. Nothing in the first group opens a socket at all. | Plane | Tools | Needs | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | Offline — pure `vike-script`, no server | [`validate_strategy`](/docs/ai/trader/tools/validate_strategy) · [`discover_params`](/docs/ai/trader/tools/discover_params) · [`list_templates`](/docs/ai/trader/tools/list_templates) · [`list_indicators`](/docs/ai/trader/tools/list_indicators) | nothing | | A `vike-datahub` server | [`run_backtest`](/docs/ai/trader/tools/run_backtest) · [`run_sweep`](/docs/ai/trader/tools/run_sweep) · [`run_walk_forward`](/docs/ai/trader/tools/run_walk_forward) · [`list_strategies`](/docs/ai/trader/tools/list_strategies) · [`list_series`](/docs/ai/trader/tools/list_series) | `--addr`, default `127.0.0.1:7878` (`DEFAULT_ADDR`) | | A running `vike-tradehub` node | [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) | `--node` + the observe key | | The same node, order-write | [`submit_order`](/docs/ai/trader/tools/submit_order) · [`cancel_order`](/docs/ai/trader/tools/cancel_order) · [`modify`](/docs/ai/trader/tools/modify) · [`flatten`](/docs/ai/trader/tools/flatten) · [`market_exit`](/docs/ai/trader/tools/market_exit) · [`mass_cancel`](/docs/ai/trader/tools/mass_cancel) · [`set_trading_state`](/docs/ai/trader/tools/set_trading_state) | `--node` + the control key | The two halves fail separately and the offline four never notice: `Server::ensure_observe` and `Server::ensure_control` run before any socket is opened, and a missing key produces an error that names both places it looked. [The server page](/docs/ai/trader) has the flag and key detail. ## The registry a session sees can be smaller than this [#the-registry-a-session-sees-can-be-smaller-than-this] `tools/list` returns the whole registry only under the default `--profile full`. An operator who launches the server as `--profile read-only` serves the first three rows above and **withholds the fourth**; `--profile offline` serves only the first; `--deny-tool NAME` takes out one more, and is repeatable. A withheld tool is absent from the advertised registry as well as refused by `tools/call`, because `ToolAccess::new` produces the advertised set and its withheld complement from one pass over `tools_spec()` — an advertised-but-refused tool, or a withheld-but-routed one, is the bug a scoping feature must not ship with. The rings are predicates rather than second rosters: `read-only` is the negation of `is_write_tool` (so an eighth write verb joins it by construction) and `offline` is each tool's own `openWorldHint` annotation read back. Which tools each ring leaves you, and how a refusal reads, is on [`vike-cli mcp`](/docs/trader/reference/cli/mcp). ## Every result has the same shape [#every-result-has-the-same-shape] A success is `tool_ok`: the payload pretty-printed into one `text` content block, the same value again as `structuredContent`, `isError: false`. A failure is `tool_err` — the message as text with `isError: true`. That is an ordinary tool *result*, not a JSON-RPC error, so the agent reads it and retries rather than seeing the call fail at the protocol layer. Even a panicking tool arrives that way: `Server::handle` wraps every call in `catch_unwind` and turns a panic into `tool panicked: `, pinned by `a_panicking_tool_becomes_iserror_not_a_dead_session`. ## Ten read, seven write [#ten-read-seven-write] The split is machine-pinned rather than conventional. Every read-only tool carries `readOnlyHint: true` and every write tool carries `destructiveHint: true` in `tools_spec()`, and `tools_list_has_read_and_write_tools_with_correct_hints` fails the build if a tool falls on the wrong side — it also asserts each write tool's *description* mentions `confirm`, so at least half the gate is part of the advertised contract. Only half: the description is what that test reads, and the **schema** carries the other half. Both properties are defined once and spliced into all seven tools — `confirm_property` and `preview_token_property` — so the wording cannot drift between them, which is how the old inline copies ended up in four different spellings. The write roster is a fixed-size const, `WRITE_TOOLS: [&str; 7]`, because it must stay the full write-verb set of the human `trade` REPL: both surfaces build their commands through the one construction site in `crate::cmd::verbs`, so they cannot drift into two vocabularies. `Server::call_tool`'s write arm reads the two together: a `preview_token` is only looked up when `confirm` is the JSON boolean `true`, and a confirm with no token binds nothing. Fail *either* condition and `preview_of` returns the resolved wire command, the advisory guardrail check, the node's own dry-run when there is a node to ask, the rationale that would be recorded — and a fresh `preview_token`. Nothing is sent, and nothing is flagged: `will_execute: false` is the only signal. `confirm` is read as a JSON boolean and nothing else, so `"true"`, `1` and `"yes"` fall through the same way. A token that is unknown, already used, expired past `PREVIEW_WINDOW` or bound to a *different* command is the loud case — a tool error naming which. The mechanism, and why the token is deliberately not a secret, is on [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not). ## The arming ceiling applies to every order-placing tool [#the-arming-ceiling-applies-to-every-order-placing-tool] A write tool mounts nothing. It hands a `WireCommand` to a node that is already running, and what that node may do to a venue was decided at *its* mount, not here. `vike_mount::make_engine` consults the deployment's per-venue arming ceiling — `policy.venues.`, ordered `paper < demo < live` — **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, because `vike_config::VenueMode::cap` is `min` and never `max`, so `venues.bybit = "live"` does not put bybit live — it declines to stop it. Below that sits the older rule, that absent credentials *are* the live gate. Two more gates stand between an accepted command and a fill, and both are server-side on the node: `ControlLimits::vet` consumes a rate token on every command and applies its notional cap only to the order-increasing verbs that carry a price (Submit and Modify — cancel, flatten, mass-cancel, market-exit and set-trading-state are risk-reducing and never size-capped), and beneath it the execution core's `RiskGate`. The preview's own guardrail is client-side advisory UX; nothing an agent sends from here can loosen either. [Risk and arming](/docs/trader/concepts/risk-and-arming) is the concept page, [`policy.toml`](/docs/trader/reference/settings/policy) the key. ## Where to start [#where-to-start] [Drive Vike from Claude](/docs/ai/trader/drive-vike-from-claude) runs the whole loop end to end; [`reference/cli/mcp`](/docs/trader/reference/cli/mcp) is the verb that starts the server. # list_indicators (/docs/ai/trader/tools/list_indicators) Lists the indicators a Rhai strategy can call. Offline: no server, no key. The answer is tiered, because the bound set is most of the catalog and a full dump would spend an agent's context on indicators it will never use. ## Input [#input] | Field | Type | Default | Answers | | ----------- | ------ | ------- | ----------------------------------------------------------------------------------------------- | | *(neither)* | — | — | the compact roster: `name` + `category` per indicator, plus a `count` and a `note` | | `name` | string | omit | one indicator in full — its parameters with defaults, the value it returns, and how to spell it | | `category` | string | omit | one family in full, matched case-insensitively | Both are trimmed and a blank string counts as omitted. `tool_list_indicators` checks `name` first and returns, so a call passing both answers the name. ```json {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_indicators","arguments":{"name":"rsi"}}} ``` ## What comes back [#what-comes-back] The compact roster is `{ "indicators": [ { "name": …, "category": … } … ], "count": N, "note": … }`. Categories are the `vike_indicators::Category` variants the built-in catalog carries — `Overlap`, `Momentum`, `Volatility`, `Volume`, `Statistics`, `Pattern`, `Price`, `Structure` — and the rows come back in registry order, which is curated so related indicators stay together, not alphabetized. The enum's one other variant, `User`, names a user-written indicator and never reaches this roster. A detail row (`name`, or every row under a `category`) adds the fields a roster cannot carry: | Field | Meaning | | ----------- | -------------------------------------------------------------- | | `pretty` | the display label | | `params` | one entry per parameter: `name`, `default`, `min`, `max` | | `outputs` | the output lines, in order | | `accessors` | the per-line spellings, as `line` and the `call` that reads it | | `bare_call` | whether the bare name resolves at all | Those last two are the pair to read together. `bare_call: false` with a non-empty `accessors` is the band-indicator shape — reachable, but only line by line. Without the flag there is no way to tell `bollinger(20)` from `macd(12, 26, 9)` by looking at `outputs`. The parameters are why detail exists: the host registers one call form per argument count, from zero up to the parameter count, and the omitted ones take the defaults printed here. ## It is the bound set, never the registry [#it-is-the-bound-set-never-the-registry] The filter is `vike_script::is_callable` — reachable under its bare name, **or** through at least one per-line accessor. That is not the same question as membership in the host's bound-name list: `bollinger` is absent from that list, because its bare name is refused when line 0 is the upper band, while `bollinger_mid(20)` is perfectly callable. A surface asking the older question reports a band indicator as unreachable and offers no way in. The same helpers render the human [`vike-cli indicators`](/docs/trader/reference/cli/research/indicators) listing, so the two surfaces cannot disagree about what a script may call — which is the only property that matters here, because the cost of being wrong is silent. Rhai resolves a registered function when the line **runs**. A script naming an unbound indicator compiles, raises `ErrorFunctionNotFound` on every bar, is swallowed fail-safe, and disables itself after 10 consecutive errors — mounted, and silently never trading. Check names here rather than from memory, and see [`validate_strategy`](/docs/ai/trader/tools/validate_strategy) for why a compile cannot catch it. ## A miss is an error, and it explains itself [#a-miss-is-an-error-and-it-explains-itself] 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. An unknown category names the ones this build has. An unknown name splits the two ways a name can miss: not in the registry at all is a typo, while a **real** indicator the host holds back answers with the host's own reason rather than a paraphrase. Those reasons are the exclusion rules themselves — a Rhai reserved word or symbol, so the call fails at parse time (`var`, for Variance; `stddev` is the way in); a collision with a host read or verb, which binding would shadow for every script; `batch_only`, meaning it reads future bars so the streamed value is retroactively revised and disagrees with the chart; more parameters than the bridge can express; or a multi-output whose line 0 is not the namesake, where the message names the accessor that works. `name` matches the registry name exactly, so an accessor spelling such as `bollinger_mid` is not a name it resolves — ask for `bollinger` and read its `accessors`. And it lists the **built-in** catalog only: an indicator you wrote into `user_data/indicators` is callable from the same script by the same spelling, but the human `vike-cli indicators` command is what prints it, under its own heading. ## The prompt [#the-prompt] > Which indicators can a Vike Trader Rhai strategy call, and what parameters does `rsi` take? The `tools/list` description deliberately names none of the roster and is size-capped, because it ships in every session whether the tool is called or not — so an agent must call the tool to get the set. [The indicator catalog](/docs/trader/indicators) is the per-category reference and [writing an indicator](/docs/trader/scripting/writing-an-indicator) the route to your own. # list_series (/docs/ai/trader/tools/list_series) `list_series` answers the question every backtest starts with: *what data is actually there?* It returns one row per stored series with the span and row count that series covers, which is what a profile's `[data]` table can name. Read-only and remote. `tools_spec` advertises it with `readOnlyHint: true`, `idempotentHint: true` and `openWorldHint: true` — it changes nothing, the same call twice gives the same answer, and it reaches off this machine to do it. ## Arguments [#arguments] None. Its `inputSchema` declares an object with no properties, so the call is the empty object. ```json { "name": "list_series", "arguments": {} } ``` ## What comes back [#what-comes-back] `Server::call_tool` routes to `tool_list_series`, which connects to the datahub, calls `DatahubClient::inventory`, and projects each row into JSON: ```json { "series": [ { "kind": "bar", "venue": "binance", "symbol": "BTCUSDT", "interval": "1m", "first_ts": 1704067200000, "last_ts": 1735689600000, "rows": 525600 } ] } ``` Seven fields, four from `vike_data::series::SeriesId` and three from `SeriesCoverage`. The `SeriesId` tuple is exactly what the store's leaf directory encodes — `kind=…/venue=…/symbol=…[/interval=…]` — so `interval` is present for bars, which sub-partition by bar step, and `null` for tick series. The layouts each `kind` implies have their own pages under [the data-kind reference](/docs/trader/concepts/data/), and the store itself is described in [The history store](/docs/trader/concepts/hist-store). Coverage is cheap on purpose. `SeriesCoverage`'s own doc states it is computed from the manifest file-index with **no DataFusion scan**, which is why an inventory over a large store is a metadata read rather than a query. ## Four fields the projection drops [#four-fields-the-projection-drops] `SeriesCoverage` also carries `bytes`, `parts` and `dates`, and `SeriesId` carries `group`. `tool_list_series` projects none of them. `SeriesId::group` is `Some(g)` for a **grouped** series — one part file holding many symbols, told apart by a row-level symbol column instead of one series per symbol. Its doc is explicit that `symbol` is **empty** in that case, deliberately, so a consumer scanning by `id.symbol` fails visibly rather than silently scanning `""`. The tool does not project `group`, so such a row arrives here as a blank `symbol` with no replacement label. `SeriesId::label` — group if grouped, else symbol — is the accessor that resolves it, and it is not on this wire. Treat a blank `symbol` as "grouped, ask the store", never as "no symbol". ## It needs a reachable datahub [#it-needs-a-reachable-datahub] The address comes from `vike-cli mcp --addr `, defaulting to `DEFAULT_ADDR`, `127.0.0.1:7878`. If nothing is listening there the tool returns a clean error result whose text begins `cannot connect to datahub at` — never a panic and never a dead stdio session. `remote_run_tools_without_a_reachable_datahub_are_clean_errors` pins that for this tool alongside `run_sweep` and `run_walk_forward`, by pointing the server at a port that refuses immediately. The `vike-datahub` server is the process that holds the store; see [its binary page](/docs/trader/reference/binaries/vike-datahub) for how it is run and [Connect an agent](/docs/trader/guides/connect-an-agent) for wiring the MCP server up in the first place. ## Where it sits in a session [#where-it-sits-in-a-session] `list_series` is the first of the three remote read tools an authoring loop uses: this one says what data exists, [`list_strategies`](/docs/ai/trader/tools/list_strategies) says which compiled strategy names a profile can resolve, and [`list_indicators`](/docs/ai/trader/tools/list_indicators) says what a Rhai script may actually call. Only then does the profile you hand [`run_backtest`](/docs/ai/trader/tools/run_backtest) name a series that is really in the store over a range that is really covered. > What series does the datahub have for binance, and how far back does the 1m BTCUSDT bar data go? # list_strategies (/docs/ai/trader/tools/list_strategies) Asks the remote `vike-datahub` server which compiled **native** backtest strategies it offers — the names a profile's `strategy.name` can resolve. Takes no arguments, requires a reachable datahub at `--addr`, and answers from a compile-time constant rather than from the store. ## Input and output [#input-and-output] The input schema is an empty object. `tool_list_strategies` connects like the run tools and calls `DatahubClient::list_strategies`, which sends the `ListStrategies` verb and reads back `Response::Strategies` — `vike_backtest::harness::STRATEGIES`, over RPC, **in its declared order**. That order is a documented wire property, so the answer is stable across calls rather than alphabetized by whoever rendered it. ```json {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_strategies","arguments":{}}} ``` The result is `{ "strategies": [ … ] }` — a flat list of names. As the const stands in this workspace, in declared order: ```json { "strategies": [ "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" ] } ``` Call the tool rather than copying that list: the roster belongs to the **server's** build, and this tool exists so an agent can discover it instead of assuming it. ## What the roster is, and is not [#what-the-roster-is-and-is-not] `STRATEGIES` is the union of two registry halves. The portable half lives in `vike_strategy::registry` and is generic over the broker, so the same strategy a backtest ran can be mounted by the live daemon. The simulator half stays in `vike-backtest` for two reasons that `vike_strategy::SIMULATOR_ONLY` keeps apart: five names are `impl Strategy` reference strategies that reach the simulator's own machinery, and two are portable in principle but reach that crate's internal modules. Either way, `vike-backtest` is the crate the live daemon deliberately does not depend on, so those names backtest and do not mount. [The portable registry](/docs/trader/strategies/the-registry) and [simulator-only strategies](/docs/trader/strategies/simulator-only) are the two pages; each portable name also has its own [template page](/docs/trader/strategies/templates) with its parameter keys. The registry has a `"rhai"` arm, and it is the one a script runs through — but it needs a `src` param, so it is not resolvable with default params. Listing it would break every consumer that enumerates this roster and resolves each name with empty params. Scripts are the *other* route to the same engine: hand your source to [`run_backtest`](/docs/ai/trader/tools/run_backtest)'s `script` argument under `name = "rhai"`, and use [`list_templates`](/docs/ai/trader/tools/list_templates) for starters. `strategy_by_name` tries the built-in arms first and then consults the user registry compiled from `user_data/strategies/rust`, so a user name resolves — but the wire verb returns `harness::STRATEGIES` and nothing else. The list is the built-in roster, not everything the server will accept. The harness's own "unknown strategy" error does name both sets, so a typo's message is more complete than this tool's answer. ## Failure modes [#failure-modes] An unknown `strategy.name` is a validation error at profile load — the harness fails fast rather than silently no-opping on a typo, which is why checking against this list before authoring a profile is worth one call. Unlike the three run verbs, `ListStrategies` is **observe** scope on the server: it returns a compile-time const roster, not store contents, and is served on every build, DataFusion or not. It is still reached here through `DatahubClient::connect`, so a datahub that advertises the `auth` feature refuses at connect like every other tool on this plane. An unreachable server is a clean tool error, never a panic — `list_strategies_without_a_reachable_datahub_is_a_clean_error` is the pin, and `list_strategies_is_advertised_read_only` holds its `readOnlyHint`. ## The prompt [#the-prompt] > Which built-in strategies can I name in a profile on this server? Then pair the name with [`list_series`](/docs/ai/trader/tools/list_series) to pick a `[data]` range, and [`run_backtest`](/docs/ai/trader/tools/run_backtest) to run it. # list_templates (/docs/ai/trader/tools/list_templates) Returns the starter Rhai strategies as `{name, code}` rows. Offline — no server, no key, no argument. Each starter is parameterized via `param()`, so it drops straight into [`run_sweep`](/docs/ai/trader/tools/run_sweep) without editing. ```json {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_templates","arguments":{}}} ``` ## What comes back [#what-comes-back] `{ "templates": [ { "name": …, "code": … }, … ] }`. The sources are static data in `mcp.rs`'s `TEMPLATES`, so the payload is fixed by the build rather than by anything on disk. On the wire each `code` is a JSON string with escaped newlines; unescaped, the three rows are: **SMA cross** ```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)); } } ``` **RSI reversion** ```rust let len = param("len", 14.0); let lo = param("lo", 30.0); let hi = param("hi", 70.0); fn on_bar() { let r = rsi(len.to_int()); if r.is_nan() { return; } if r < lo && position() <= 0.0 { market(1, 1.0); } if r > hi && position() >= 0.0 { market(-1, 1.0); } } ``` **Donchian breakout** ```rust let len = param("len", 20.0); fn on_bar() { let h = sma(len.to_int()); // symmetric proxy. 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. if h.is_nan() { return; } if high() > h && position() <= 0.0 { market(1, 1.0); } if low() < h && position() >= 0.0 { market(-1, 1.0); } } ``` Read all three as *shapes*, not as edges: each guards its warm-up with an `is_nan()` early return, each reads its knobs at the top level where `param()` runs, and each does its trading inside `fn on_bar()`. ## The gate that makes this list safe to copy [#the-gate-that-makes-this-list-safe-to-copy] Two tests stand behind the rows, and the second is the one that matters. One compiles every template and asserts it exposes `param()` knobs and defines `on_bar`. The other, `every_shipped_template_reaches_the_broker`, mounts each one as a real `RhaiStrategy` over a broker double, drives 80 strictly-rising bars through it, and asserts an order actually arrives — routed to the bar's own symbol, with a side of exactly ±1 and a positive quantity. Compiling is the wrong bar for a surface an agent copies from. The compile path runs a script's top level only, so every call inside `on_bar` is unchecked, and an unbound name, a wrong arity or a wrong argument type all compile cleanly and then raise on every bar until the strategy disables itself. Only execution distinguishes that from a strategy that simply saw no signal — which is why the gate runs the templates rather than parsing them. It stands in with `sma`, symmetrically. A bare multi-output call binds only when line 0 is the indicator's namesake line, and `donchian` declares `upper`/`mid`/`lower` — so `donchian(len)` is a function-not-found on the first bar rather than a silent upper-band read. Every line is separately reachable through a per-line accessor (`donchian_mid()` is the spelling). The template's own comment says "the bridge binds single-output indicators only", which was the rule when it was written: the host used to reject every multi-output indicator outright and has since narrowed to the namesake test — a multi-output indicator whose line 0 *is* its namesake now binds under its bare name, and `donchian` still does not. The conclusion the comment draws is unchanged. Check any indicator name against [`list_indicators`](/docs/ai/trader/tools/list_indicators) before you edit one in. The MCP copy is a hand copy of `vike-studio-core`'s `templates`, because importing that crate would drag DataFusion into a deliberately DataFusion-free CLI. There is no cross-crate drift gate — each side instead carries its own execution gate over its own copy. If you are changing a starter in the product tree, change both. ## The prompt [#the-prompt] > Show me the Vike Trader strategy templates and explain what each one trades. The natural next step is to edit one and run [`validate_strategy`](/docs/ai/trader/tools/validate_strategy), then [`discover_params`](/docs/ai/trader/tools/discover_params) to confirm the knobs a `[sweep]` may vary. [Scripting](/docs/trader/scripting) is the language reference; the same starters are what the Studio's templates dropdown renders. # market_exit (/docs/ai/trader/tools/market_exit) `market_exit` is the get-me-out verb. Its own tool description calls it the panic button, and it is the one command that empties the order book and the positions together. It previews unless a call carries **both** `confirm: true` **and** the `preview_token` its own preview returned, and needs `vike-cli mcp --node ` plus `VIKE_TRADEHUB_CONTROL_KEY`. This verb is the one where the two-call gate costs something real: in an incident, a `confirm: true` sent without the `preview_token` does not fire the exit and does not complain — it hands back another preview. Take the preview, read `preview_token` out of it, and send the second call with both fields. Then read `will_execute`. The token is good for 60 seconds, which is the window this gate was sized for. ## Arguments [#arguments] | Parameter | Type | Required | Notes | | --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- | | `venue` | string | no | Omit for every engine | | `reason` | string | no | Rationale for 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 | Nothing is required. `market_exit_preview_allows_an_omitted_venue` pins that a call with an empty argument object previews cleanly and produces a `MarketExit` wire command. `venue: None` is the global exit across every engine; `Some(v)` scopes it to that venue's engine alone. ## It is a compound verb, expanded on the node [#it-is-a-compound-verb-expanded-on-the-node] `OrderIntent::MarketExit` carries no state of its own and mints no orders directly. The runtime expands it **at apply time** into the primitives that already exist: 1. one `MassCancel`, applied **first**; 2. then one `Flatten` per non-flat `BOTH`-side position, derived from the **post-cancel** account, in the account's own `(venue, symbol, position_side)` insertion order. Hedge-mode `LONG`/`SHORT` rows are **skipped**. `Flatten` resolves its size at `(venue, symbol, "BOTH")`, so a hedge leg would expand into a `Flatten` that resolves `0.0` and no-ops. On an account whose venue reports only hedge buckets — binance perp's `positionSide`, OKX's `posSide`, bybit's `positionIdx` — the mass-cancel still runs but the flatten half mints **nothing**. Those venues need per-leg closing intents the primitive vocabulary does not carry yet. Each leg is lowered through the *same* `apply_intent` site every other order takes, so mint → `RiskGate` → client stays structural rather than being a special path for the emergency verb. A flat, order-free book is a no-op beyond the mass-cancel. ## It works from a halted core [#it-works-from-a-halted-core] The `RiskGate` kill switch admits a position-covered reduce under `Halted` (`vike_model::is_covered_reduce`), which is exactly the shape a flatten leg mints: side opposite the position, quantity `|position|`. So the mass-cancel runs **and** every flatten leg is submitted, under `Active`, `Reducing` and `Halted` alike — though admission at that arm is all the covered shape buys: `RiskGate::check_inner`'s per-order notional and projected-exposure caps carry no covered-reduce bypass, so a leg over an armed `max_notional_per_order` or `max_total_exposure` is denied in any state. And one residual under `Halted` alone (`Reducing` trusts the flag): the gate re-takes its halt verdict against the **lot-rounded** size, so a position sitting off the lot grid (`|position| = 1.8` on a `1.0` lot) rounds its own leg up to `2.0`, which would flip the position, and that leg comes back `OrderDenied` with the reason `halted`. Every size this core mints is already lot-rounded, so reaching that residual takes an externally-sourced position — a venue liquidation, a reconcile fold. The runtime still logs a warning and a recent-events note when the exit runs under a halt — now saying it is *proceeding*. The gate used to deny every order under `Halted`, `reduce_only` included, so the panic button was disarmed in exactly the situations that reach `Halted` on their own — both dead-man's switches and the safe-state sweep halt — and an operator had to set the state back to active first, un-halting the strategy that got them there in order to escape it. A kill switch must stop you opening risk, never trap you in it. ## Best-effort, not atomic [#best-effort-not-atomic] Against a real venue the mass-cancel is fire-and-forget over the adapter's actor thread. Its acknowledgements land asynchronously, possibly **after** the flatten market orders are already on the wire. Ordering the legs is a local best effort, not a venue-side transaction. `OrderIntent::MarketExit`'s own instruction is the one to follow: **re-issue the exit if the board is not flat afterwards.** Read the board with [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) rather than assuming the first exit finished the job. ## What the node does with it [#what-the-node-does-with-it] `lower_command` maps `WireCommand::MarketExit` to the core intent with an **empty** coid — the account-wide-verb convention, since there is no single order id to echo. `ControlLimits::vet` consumes a rate token and applies no size cap: market-exit sits with cancel, flatten, mass-cancel and trading-state among the risk-reducing verbs its doc says are never size-capped. The preview's guardrail is `Guardrail::NoSize` for the same reason a flatten's is — there is no order size on this side to check, and the orders this verb will produce do not exist yet. What the preview *can* give you is the node's own dry-run (`node_verdict`, when there is a node to ask) and the `preview_token` the confirming call has to carry; [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) sets out how that token behaves. ## Outcomes [#outcomes] `Server::execute` awaits this command's own ticket for two seconds. `accepted` returns `sent: true` with an empty `client_order_id`; a refusal is an error result carrying the node's reason; a timeout returns `outcome: "unknown"`; a dropped control connection is an error result saying the outcome is unknown and it may have executed. For this verb in particular, the ambiguous answers deserve the snapshot rather than a reflex retry — though a second exit is the documented remedy for a board that is not flat, so the cost of checking first is small and the cost of guessing is not. ## Related tools [#related-tools] [`mass_cancel`](/docs/ai/trader/tools/mass_cancel) is the first half on its own — clear the orders, leave the positions. [`flatten`](/docs/ai/trader/tools/flatten) is the second half for one book. [`set_trading_state`](/docs/ai/trader/tools/set_trading_state) halts new risk without closing anything. The gates each leg passes are described in [Risk and arming](/docs/trader/concepts/risk-and-arming). > Market exit everything on binance, right now — reason: feed outage. # mass_cancel (/docs/ai/trader/tools/mass_cancel) `mass_cancel` clears the order book without touching positions. It is the coarse companion to [`cancel_order`](/docs/ai/trader/tools/cancel_order), and the first half of what [`market_exit`](/docs/ai/trader/tools/market_exit) does. Annotated `destructiveHint: true`, it previews unless a call carries **both** `confirm: true` **and** the `preview_token` its own preview returned, and needs `vike-cli mcp --node ` plus `VIKE_TRADEHUB_CONTROL_KEY`. ## Arguments [#arguments] | Parameter | Type | Required | Notes | | --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- | | `venue` | string | no | Omit for every venue | | `symbol` | string | no | Omit for every symbol | | `reason` | string | no | Rationale for 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 | Nothing is required. `mass_cancel_preview_allows_an_omitted_scope` pins that the empty argument object previews cleanly and produces a `MassCancel` wire command with **both** `venue` and `symbol` `null` — the whole account, shown as exactly that. The preview also carries a **`preview_token`**, and clearing the book takes a second call with **both** `confirm: true` **and** that token. `confirm: true` on its own is not an error and does not cancel anything: it returns a fresh preview, which `mass_cancel_confirm_without_a_token_is_a_preview_not_a_send` pins by name. Read `will_execute` before believing the book is empty — or read it from [`node_snapshot`](/docs/ai/trader/tools/node_snapshot), which is the real answer either way. The token's rules — fires once, 60-second window, bound to this exact scope — are on [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not). ## What each scope means [#what-each-scope-means] `OrderIntent::MassCancel` is the authority, and it enumerates four combinations rather than three: | `venue` | `symbol` | What happens | | ------- | -------- | ---------------------------------------------------------------- | | absent | absent | **all** engines, and all conditional books cleared | | present | present | that engine, and that `(venue, symbol)` conditional book cleared | | present | absent | that engine, and that venue's conditional books cleared | | absent | present | **ignored**, surfaced to recent-events | The fourth row is the one to carry. A `symbol` on its own does not mean "this symbol everywhere" — the intent is ignored, and the fact that it was ignored is surfaced to the node's recent-events rather than raised back to you as a refusal. The tool call itself succeeds, the node acknowledges the command, and no order is cancelled. Always name the `venue` when you name a `symbol`, and check [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) if a scoped cancel appears to have done nothing. ## It clears armed conditionals too [#it-clears-armed-conditionals-too] The scope table above is not only about resting venue orders. `MassCancel` is also the **coarse verb** over the core-owned conditional book — the emulated stops and trailing orders a strategy arms — and each scope clears the matching books as the table shows. `OrderIntent::DisarmConditional` is the individual-cancel primitive that exists alongside it, removing exactly one arm by the `arm_id` the runtime minted; `MassCancel` stays the blunt one. An agent has no tool for the fine-grained disarm, so a mass cancel is how an emulated arm goes away from this surface. ## What the node does with it [#what-the-node-does-with-it] `lower_command` maps `WireCommand::MassCancel` to the core intent and echoes an **empty** coid — the account-wide-verb convention, since there is no single order id to name. The accepted response says as much in its own note. `ControlLimits::vet` consumes a rate token, as it does for every command, and applies no size cap: mass-cancel is listed with cancel, flatten, market-exit and trading-state among the risk-reducing verbs its doc says are never size-capped. The preview's guardrail is correspondingly `Guardrail::NoSize` — `within_limits: true`, with a note saying there is no order size to check for this verb. ## Outcomes [#outcomes] `Server::execute` awaits this command's own ticket for two seconds. `accepted` returns `sent: true` with an empty `client_order_id`; a refusal is an error result carrying the node's reason; a timeout returns `outcome: "unknown"`; a dropped control connection is an error result saying the outcome is unknown and it may have executed. Both ambiguous cases end at [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) before a retry. A repeat is cheap here — cancelling an already-empty book is nothing — but it still spends a rate token, and the snapshot is the only thing that tells you whether the first one landed. ## Related tools [#related-tools] [`cancel_order`](/docs/ai/trader/tools/cancel_order) withdraws one order by id. [`market_exit`](/docs/ai/trader/tools/market_exit) runs a mass cancel and *then* flattens every one-way position, in that order — hedge-mode `LONG`/`SHORT` legs are skipped. [`flatten`](/docs/ai/trader/tools/flatten) closes one book's position without touching orders. What happens to each cancelled order after the command lands is described in [Order lifecycle](/docs/trader/concepts/order-lifecycle). > Cancel all resting orders on okx — venue okx, no symbol scope. # modify (/docs/ai/trader/tools/modify) `modify` amends a resting order in place rather than cancelling and re-submitting it. Like every write tool it is annotated `destructiveHint: true`, previews unless a call carries **both** `confirm: true` **and** the `preview_token` its own preview returned, and needs `vike-cli mcp --node ` plus `VIKE_TRADEHUB_CONTROL_KEY`. ## Arguments [#arguments] | Parameter | Type | Required | Notes | | ----------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- | | `client_order_id` | string | yes | The order to amend | | `new_qty` | number | no | New quantity — but see below for what that *means* | | `new_price` | number | no | New price | | `reason` | string | no | Rationale for 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 | At least one of `new_qty` / `new_price` is required. `verbs::verb_from_tool_args` refuses a modify that changes nothing, mirroring the REPL's rule — a modify that changes nothing is an authoring mistake, not a command — and `modify_with_nothing_to_change_is_a_tool_error` pins that the error text names `new_qty` so the agent can act on it. ## The preview [#the-preview] `modify_without_confirm_only_previews_the_shared_wire_command` is the pin for the whole path: a call with `client_order_id: "c-1"` and `new_qty: 2.0` succeeds with no node configured, returns `will_execute: false`, and shows a `wire_command` whose `Modify` carries `c-1`, `new_qty` `2.0` and `new_price` `null`. Its guardrail reports `within_limits: true` — this verb has no order size for the *client-side* check to look at. That is exactly the same `WireCommand::Modify` the human REPL's `modify c-1 --qty 2` builds. Both surfaces resolve through `verbs`' one construction site, `Verb::to_wire_command` — the tool path reaching it via `wire_command_for` — which is why an agent and an operator issue the same command. The preview also hands back a **`preview_token`**, and a call that actually amends the order must carry **both** `confirm: true` **and** that token. Either one alone returns another preview rather than an error, so `will_execute` is what tells you which call you just made. The token fires once, expires after 60 seconds, and is bound to *this* modify — confirming a differently-sized amend with it is refused, and consumes it. [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) is the full rule set. The binding compares the whole wire command, so changing your mind about `new_price` between the preview and the confirm invalidates the token you were holding — deliberately. Preview the amend you actually intend to send, then confirm with *that* token. ## Under a notional ceiling, the node refuses a quantity raise with no price [#under-a-notional-ceiling-the-node-refuses-a-quantity-raise-with-no-price] `ControlLimits::notional_reason` has a dedicated arm for a modify naming `new_qty` and **no** `new_price`: on a node whose `policy.toml` sets `max_notional_per_order`, it is refused, with a message naming that ceiling and telling you to re-send with an explicit price. A node with no ceiling set never reaches the arm — the check returns early with nothing to enforce. The reason is stated where the code performs it — this edge holds no order registry and no book, so it cannot resolve the resting price needed to size the projected order, and "I cannot evaluate this ceiling" must never render as "the ceiling passed". The shape used to fall through as "no price to size, therefore fine", which let a small in-cap order be placed and then modified straight past the node's one order-size ceiling. A modify naming both a quantity and a price **is** size-checked, against `|new_qty| × |new_price|` — magnitudes on both factors — and refused above the node's ceiling. A modify naming only a price projects no quantity and matches none of the sizing arms, so it is not notional-checked at this edge; the core `RiskGate` resolves the resting order's terms and is the enforcing gate regardless. Every modify, whatever it names, still spends a rate token in `ControlLimits::vet`. ## What the quantity means is a per-venue fact [#what-the-quantity-means-is-a-per-venue-fact] `vike_model::venue_amend` exists to answer one question: *what does an amend's quantity mean?* Three conventions exist, and they disagree about how much more the order can still execute. | Convention | The order | How much more can execute | | ------------------ | ---------------------------------------------------- | ------------------------------------------------------ | | `InPlaceTotal` | survives, executions stay attached | `new_qty − filled_qty` | | `CancelReplace` | dies; a fresh order rests | the whole `new_qty`, on top of what the dead order did | | `InPlaceRemaining` | survives; the number replaces its *outstanding* size | the whole `new_qty` | Only `InPlaceTotal` changes any pre-trade verdict — `already_in_position` returns a non-zero value for that variant and `0.0` for every other, including the two `AmendSemantics` variants that declare no convention at all: `Unsupported`, where the venue has no native amend for a convention to describe, and `Unknown`, the value an unrecognised venue string resolves to. The asymmetry is the safety argument: the arithmetic errs conservative, so it can wrongly deny an amend and can never wrongly admit one. It is tempting to assume a paper mount runs under `sim` or `paper` and falls into the conservative fallback for free. It does not: `vike_mount::make_engine` builds the engine with the **real** venue string even when the absent-credentials gate fell back to the paper client. The paper book implements the third convention — its `modify` assigns the amend's quantity as the size that can still fill — so it declares `InPlaceRemaining` through the client rather than through the venue table. ## Outcomes [#outcomes] `lower_command` maps `WireCommand::Modify` to the core's `OrderIntent::Modify` and echoes the `client_order_id`, since this is an order-scoped verb. `Server::execute` then awaits that command's own ticket for two seconds: `accepted` with the echoed id, an error result carrying the node's refusal, `outcome: "unknown"` on a timeout, or an error result naming the outcome unknown when the control connection dropped. Both ambiguous cases mean the same thing — call [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) before you retry, because a re-sent modify is not idempotent the way a re-sent submit is. If the amend is large enough to be a different order, consider [`cancel_order`](/docs/ai/trader/tools/cancel_order) plus a fresh [`submit_order`](/docs/ai/trader/tools/submit_order) instead; the states involved are described in [Order lifecycle](/docs/trader/concepts/order-lifecycle). > Move my resting bid to 59,800 and cut the size to 0.2 — name the price, it is a qty change. # node_snapshot (/docs/ai/trader/tools/node_snapshot) `node_snapshot` reads the live state of a running `vike-tradehub` node. It is the only read-only tool in `vike-cli mcp` that talks to a node rather than to a datahub, annotated `readOnlyHint: true` and `openWorldHint: true` in `tools/list` (`crates/vike-cli/src/cmd/mcp.rs`'s `tools_spec`). It needs `--node ` and `VIKE_TRADEHUB_OBSERVE_KEY` — the *observe* key, not the control key (`crates/vike-cli/src/cmd/nodekeys.rs`'s `OBSERVE_KEY_ENV`). ## Input schema [#input-schema] It takes no arguments: `"inputSchema": { "type": "object", "properties": {} }`. It is also the one node tool with no `reason` property — a read has no rationale to file, and the tools/list test in `crates/vike-cli/src/cmd/mcp.rs` asserts the property is absent. ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"node_snapshot","arguments":{}}} ``` ## What comes back [#what-comes-back] `Server::tool_node_snapshot` serializes the node's `WireSnapshot` as-is — "already a flat, serde projection". The schema is `crates/vike-tradehub-client/src/wire.rs`'s `WireSnapshot`, a standalone mirror of the core's rendered snapshot rather than the core type itself, so an internal refactor cannot silently change what an agent reads: | Field | What it holds | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `seq` | per-publish sequence number; `0` means no real frame has arrived yet | | `venue`, `symbol` | the primary engine's mount | | `trading_state` | `Active`, `Reducing` or `Halted` for the primary engine | | `balance` | the primary venue's cash balance | | `equity_total` | cross-venue total equity | | `venues` | per-venue ledger blocks — balance, realized PnL, fees, funding, equity, unrealized, margin used, free buying power, that venue's own `trading_state`, and its positions | | `orders` | the order registry in insertion order, spanning **every** engine; `status` is the rendered string | | `positions` | the primary venue's positions — the documented mirror of `venues[0].positions` | | `held_exits` | pending bracket exits held off the venue until their OTO parent fills; empty on non-bracket runs | | `recent_events` | the bounded delivered-event tail, already rendered to lines | | `fault` | set once a handler panicked — the core is halted in safe state | | `bars` | bounded OHLCV tails for the chart | | `identity` | which daemon, which strategy, paper or live, which build | `bars` and `identity` are `#[serde(default)]`, so a node that predates either field simply omits it. Two bounds are applied node-side, in `crates/vike-tradehub/src/publish.rs`'s `project`. `bars` is filtered to the **primary** `(venue, symbol)` only and tail-sliced to `CHART_BARS_CAP` closed bars plus the forming candle — the core's own bar vector is unbounded and would eventually overflow the framer. `recent_events` is the core's own bounded ring. ## How fresh it is, and what it is not [#how-fresh-it-is-and-what-it-is-not] The observe connection is opened lazily on the first call and then **kept open** — it stays subscribed so the node keeps pushing frames, which is why the MCP server holds persistent connections rather than dialling per call. A freshly-subscribed connection answers with `WireSnapshot::empty` (`seq: 0`) until the first frame lands, so `tool_node_snapshot` polls every 50 ms for up to about two seconds for a real one before returning. You get the latest published state, never every intermediate one. The node's publisher samples the core's arc-swap cell on a `POLL_INTERVAL` of 15 ms against a core that publishes coalesced at 16 ms or slower, and each subscriber's mailbox is a bounded `MAILBOX_CAP` drop-**oldest** queue, so a slow reader loses old frames rather than back-pressuring the publisher (`crates/vike-tradehub/src/publish.rs`). Never treat two consecutive snapshots as a complete event history — `recent_events` is a tail, not a ledger. Nothing here touches the core's hot fold. The publisher's only interaction with the core is one atomic load of the already-published snapshot, on its own thread; reading a snapshot cannot slow the node down. ## Its other job: settling an ambiguous write [#its-other-job-settling-an-ambiguous-write] Every write tool points here when it cannot answer. `Server::execute` waits `ACK_WAIT` — two seconds — for the node's verdict on the command it just sent, and two of the four outcomes are genuinely unknown rather than failed: a timeout returns `outcome: "unknown"`, and a dropped control connection returns an error saying the outcome is unknown and the command *may have executed*. Both say the same thing: call `node_snapshot` **before** retrying, because retrying blind is how one intent becomes two orders. Acceptance is the same story from the other side — it means the node took the command, not that the order filled. ## When it errors [#when-it-errors] Both failures are clean tool errors, never panics. * **No node configured** — `ensure_observe` returns `no vike-tradehub node configured — pass --node` before any network work happens; `node_snapshot_without_node_is_a_clean_error` pins it. * **No observe key** — the error names *both* places the key is looked for, the process environment and the credential store, and points at `vike-cli secrets path` and `vike-cli secrets list`. The message says both because it once said only "not set in the environment" while the daemon's own keys sat unread in the store. A non-null `fault` is not a tool error but is the most important thing in the payload: the core entered safe state, which halts every engine and sweeps its working orders (`crates/vike-core/src/runtime/watchdog.rs`'s `enter_safe_state`). Concepts: [Portfolio and snapshot](/docs/trader/concepts/portfolio-and-snapshot), [The node protocol](/docs/trader/concepts/node-protocol). Tools: [`set_trading_state`](/docs/ai/trader/tools/set_trading_state), [`market_exit`](/docs/ai/trader/tools/market_exit). > Show me the node snapshot — what is open, where is the equity, and is anything faulted? # run_backtest (/docs/ai/trader/tools/run_backtest) Runs one backtest on the remote `vike-datahub` server named by `--addr` and returns the `BacktestReport` JSON. Read-only in the MCP sense — a backtest mutates nothing — and annotated `readOnlyHint: true` in `tools_spec()`. ## Input [#input] | Field | Type | Required | Meaning | | --------- | ------ | :------: | -------------------------------------------------------------------- | | `profile` | string | yes | A backtest profile TOML: `[data]`, `[strategy]`, optional `[engine]` | | `script` | string | no | Rhai source, injected as `[strategy.params].src` | `tool_run_backtest` reads `profile`, and a missing one is a tool error before anything connects. With `script` present it rewrites the profile through `crate::cmd::backtest::inject_script_src`: the profile is parsed as a `toml::Value` (a malformed one is a clean error, not a panic), `[strategy]` and `[strategy.params]` are created if absent, and `src` is set — existing params, the numeric knobs a `[sweep]` varies, are preserved, and any pre-existing inline `src` is overwritten. `src` is read by exactly one arm of `vike_backtest::harness::registry`'s `strategy_by_name` — the `"rhai"` arm, which is deliberately absent from the `STRATEGIES` roster because it is not resolvable with default params. Inject a script under `name = "buy_hold"` and the script is carried along as an unread param while the built-in strategy runs. ## A call [#a-call] The transport is newline-delimited JSON-RPC 2.0 — one message per line, no embedded newlines — so each of these goes on the wire as a single line. ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_backtest","arguments":{ "profile": "[data]\nvenue = \"binance\"\nsymbols = [\"BTCUSDT\"]\nkind = \"bar\"\ninterval = \"1d\"\nfrom = \"2024-01-01T00\"\nto = \"2024-06-01T00\"\n\n[strategy]\nname = \"buy_hold\"\n" }}} ``` The profile crosses the wire **verbatim**. The server's own `BacktestProfile::from_toml_str` is the only parser in the loop, so a profile-shape error — a bad range, an unknown `strategy.name`, a `[data]` table naming a series the store does not hold — arrives from the server as `profile parse/validate failed: …` and reaches the agent as an `isError` result. One parser, one error source. [Run profiles](/docs/trader/backtesting/run-profiles) documents the file itself. ## What comes back [#what-comes-back] `{ "report": … }`, wrapping the `BacktestReport` exactly as the server serialized it. Its fields are fixed by `vike_analytics::report::BacktestReport`; the numbers are your run's. | Field | Meaning | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | the profile's free-form `name`, or `null` | | `final_equity` | closing equity | | `total_return` | fractional return from the first to the last equity-curve point | | `n_trades` · `win_rate` | trade count, and the fraction with positive PnL | | `sharpe` | annualized Sharpe of per-bar returns; 252 for daily bars | | `max_drawdown` | largest peak-to-trough drop, a positive fraction of the peak | | `profit_factor` | gross profit over gross loss — **`null` when non-finite**, so a run with no losing trades but some profit reads as "no meaningful ratio" rather than a number | | `funding_paid` | net perp funding cashflow, received-positive; `0.0` for a spot run | | `per_symbol_pnl` | multi-symbol event runs only; empty otherwise | | `zero_trade` | present **only** when the run closed no trades *and* equity never moved — a ranked list of probable causes, not an empty stats block | That last field is the one worth reading first on a disappointing run: `ZeroTradeReport::analyze` composes it from diagnostic counters the run already carried, so "why did nothing happen" has an answer instead of a table of zeros. [The report](/docs/trader/analytics/report) and [metrics](/docs/trader/analytics/metrics) define every number. Every `Run*` verb is **control** scope on the server, because it compiles client-supplied Rhai — `vike_datahub::server`'s `required_scope` says so, and calls that a design decision rather than an oversight. The MCP server connects with `DatahubClient::connect`, which against a datahub advertising the `auth` feature fails immediately with an actionable message rather than connecting and having every verb refused a round trip later. Against a key-less datahub — the default, and the usual localhost or SSH-tunnelled shape — nothing changes. An unreachable server is likewise a clean tool error, never a panic: `tool_run_backtest` maps the connect failure to `cannot connect to datahub at …`. The pin that drives that shape, `remote_run_tools_without_a_reachable_datahub_are_clean_errors`, points at the sibling run tools — `run_sweep`, `run_walk_forward` and `list_series` — rather than at this one. ## The prompt [#the-prompt] > Backtest this script on BTCUSDT daily bars for the first half of 2024 and tell me whether it > traded at all. Pair it with [`validate_strategy`](/docs/ai/trader/tools/validate_strategy) before the run and [`run_walk_forward`](/docs/ai/trader/tools/run_walk_forward) after it. The human twin of this tool is [`vike-cli backtest`](/docs/trader/reference/cli/research/backtest), which ships the same TOML over the same verb. # run_sweep (/docs/ai/trader/tools/run_sweep) Runs a parameter-grid sweep on the remote `vike-datahub` server and returns the ranked `SweepReport`. Same input as [`run_backtest`](/docs/ai/trader/tools/run_backtest) — a profile TOML, optionally with a Rhai script injected — plus one grid table and one ranking knob. ## Input [#input] | Field | Type | Required | Meaning | | --------- | ------ | :------: | --------------------------------------------------------------------------------- | | `profile` | string | yes | Profile TOML with `[data]`, `[strategy]` and `[sweep]` (plus optional `[engine]`) | | `script` | string | no | Rhai source, injected as `[strategy.params].src` | | `rank_by` | string | no | `sharpe` (default), `return`, `max_dd` or `equity` — applied server-side | The `[sweep]` table is the whole grid: each key names a `strategy.params` field and each value is an array to cross-product over. `expand_sweep` turns the profile into that 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 single-backtest profile. A profile with no `[sweep]` table comes back as a server-side error that quotes the fix. ```json {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_sweep","arguments":{ "profile": "[data]\nvenue = \"binance\"\nsymbols = [\"BTCUSDT\"]\nkind = \"bar\"\nfrom = \"0\"\nto = \"100000\"\n\n[strategy]\nname = \"buy_hold\"\n\n[sweep]\nfast = [5, 10]\n", "rank_by": "sharpe" }}} ``` `run_tools_reject_a_missing_profile_arg_before_any_connect` pins the local half: a missing `profile`, or a `script` injection into unparsable TOML, errors before a socket is opened. Everything else is the server's answer, because the TOML ships verbatim and `BacktestProfile::from_toml_str` is the only parser. ## Ranking happens on the server [#ranking-happens-on-the-server] `rank_by` is resolved by the canonical `harness::RankMetric::from_str_ci`. An unrecognized value is a clean error naming the valid set — never a silent fallback to the default — and an omitted one ranks by Sharpe, matching the shipped `backtest` bin. Direction is folded in: `sharpe`, `return` and `equity` sort descending, `max_dd` ascending, because a smaller drawdown is better. A NaN key — a Sharpe over a zero-variance curve, say — sorts last among the successes, so a degenerate point can never rank first, and rows whose run failed always sort behind every success. No client re-implements a metric. The ranking runs over real `BacktestReport`s with the canonical comparator, which is the same rule that took sharpe and max-drawdown out of the `vike-cli sweep` verb: nothing outside `vike-backtest` computes them. ## What comes back [#what-comes-back] `{ "sweep": … }` wrapping a `SweepReport`: `rows` plus the `rank_by` it was ranked under. Each `SweepRow` carries * `overrides` — the `(key, value)` pairs that produced this point, * `report` **or** `error` — never both; a point that failed records its error string and is not dropped, * `score` — stamped only by the objective path, and skipped on serialization otherwise, so a default sweep's JSON is unchanged by the seam's existence. Read the head of `rows` as the winner and the tail for what broke. [Sweeps](/docs/trader/backtesting/sweeps) documents the expansion, [objectives](/docs/trader/backtesting/objectives) the pluggable scalar the `score` field belongs to, and [search](/docs/trader/backtesting/search) the successive-halving and TPE lanes that fold through the same per-point plumbing. ## Determinism, and the memory shape [#determinism-and-the-memory-shape] Points run on a rayon pool, and the ordering does not depend on scheduling: the collect is order-preserving and the ranking sort is stable over that input, so a parallel sweep's bytes equal the one-at-a-time loop's — `parallel_and_sequential_sweeps_are_byte_identical` is the pin. The shared store handle is shared; the **data** is not. Every point independently re-scans and fully materializes its own slice, so peak memory and store I/O are `N_threads x slice`. The pool is therefore bounded rather than one worker per core — `min(4, available_parallelism())` by default, raised by `VIKE_SWEEP_THREADS` on the server, and pinned back to one at a time by `VIKE_SWEEP_SEQUENTIAL=1`. On a large recorded tick slice an uncapped sweep is an OOM shape, and these sweeps often run on a box that also hosts live trading. Size the multiplier against the slice, not against the core count. The keyed-datahub and unreachable-server behaviour is identical to [`run_backtest`](/docs/ai/trader/tools/run_backtest)'s: every `Run*` verb is control scope because it compiles client-supplied Rhai, and a connect failure is a clean tool error. ## The prompt [#the-prompt] > Sweep `fast` over 5, 10 and 20 against `slow` over 50 and 100, rank by Sharpe, and show me the > top three rows with their parameters. A grid returns its best row whether or not the edge survives out of sample — follow it with [`run_walk_forward`](/docs/ai/trader/tools/run_walk_forward). The human twin is [`vike-cli sweep`](/docs/trader/reference/cli/sweep); the guide is [Run a sweep on the data service](/docs/trader/guides/sweep-on-the-service). # run_walk_forward (/docs/ai/trader/tools/run_walk_forward) Runs an anchored out-of-sample walk-forward on the remote `vike-datahub` server and returns the stitched `WalkForwardReport`. This is the tool that asks whether a sweep's winner was an edge or a fit: a grid search returns its best row either way. ## Input [#input] | Field | Type | Required | Meaning | | --------- | ------ | :------: | --------------------------------------------------------------------------------------- | | `profile` | string | yes | Profile TOML with `[data]`, `[strategy]` and `[walkforward]` (plus optional `[engine]`) | | `script` | string | no | Rhai source, injected as `[strategy.params].src` | There is no split-count argument. The count rides **in** the profile, as `[walkforward].n_splits`, a first-class section like `[sweep]` — so one file describes the whole run and the same text can be shipped verbatim to a remote runner, which is the point of the compute-to-data verb. A profile with no `[walkforward]` table is a clean server-side error that quotes the two lines to add; `n_splits` must be at least 1, and `BacktestProfile::validate` rejects `0` at load. Only the split count is configurable, deliberately: the mode is always `WalkMode::Anchored` and the annualization is derived by `periods_per_year`. Those are the same two derivations the Studio's walk-forward makes, so a profile cannot disagree with the Studio about what "walk-forward" means. ```json {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_walk_forward","arguments":{ "profile": "[data]\nvenue = \"binance\"\nsymbols = [\"BTCUSDT\"]\nkind = \"bar\"\ninterval = \"1d\"\nfrom = \"2023-01-01T00\"\nto = \"2024-06-01T00\"\n\n[strategy]\nname = \"buy_hold\"\n\n[walkforward]\nn_splits = 4\n" }}} ``` ## Bar mode, one series, by construction [#bar-mode-one-series-by-construction] `walk_forward_strategy` splits a single `&[Bar]` series by index, so a tick-mode or multi-symbol profile is a clean validation error — never a silent fall back to the first symbol. Every precondition is checked *before* the window loop, because the loop's closure cannot return one. What the profile route buys over the Studio's DTO-shaped walk-forward is the whole `[engine]` surface: the fee **schedule**, `[engine.impact]`, `[engine.resolution]`, `[risk]`, `snap_to_properties`, `attach_funding` — instead of the three flat `cash`/`fee_rate`/`slippage` scalars a wire DTO can carry. The strategy resolves through the same `strategy_by_name` registry a plain run uses — once up front as a pre-flight, so a bad name fails before the window loop, then rebuilt inside the closure because each window needs a fresh strategy — the bars are loaded through the same helpers, and each window is a fresh run at `engine.cash`, so a walk-forward can never disagree with a single backtest about what the profile's `[engine]` meant. ## What comes back [#what-comes-back] `{ "walkforward": … }` wrapping a `WalkForwardReport`: | Field | Meaning | | ------------------ | ---------------------------------------------------------------------------------------------------- | | `windows` | one entry per OOS window: `test_range` as `(start, end)` bar indices, and that window's `oos_return` | | `oos_equity_curve` | every window's curve rebased onto one running equity | | `oos_return` | the stitched fractional return over the whole walk | | `oos_sharpe` | Sharpe of the stitched curve, under the derived annualization | | `wf_consistency` | the fraction of windows that were profitable out of sample | The stitch is what makes the curve readable as one line: each window runs fresh at `engine.cash`, and its curve is rebased onto the running equity that the previous window ended at. So `oos_return` compounds the windows rather than averaging them, and `wf_consistency` answers the different question — how many windows worked, not how much they made. A strategy with one enormous window and three losing ones has a good `oos_return` and a `wf_consistency` of 0.25, and the second number is the one that should stop you. It is the input the overfitting verdict consumes, not a verdict itself. The anti-overfitting statistics live in [`analytics/overfitting`](/docs/trader/analytics/overfitting); walk-forward supplies them with honest out-of-sample windows. ## Failure modes [#failure-modes] Identical to the other two run tools. The profile ships verbatim, so a shape error surfaces from the server as `profile parse/validate failed: …` and arrives as an `isError` result; an unreachable datahub is `cannot connect to datahub at …` and never a panic (`remote_run_tools_without_a_reachable_datahub_are_clean_errors`); and every `Run*` verb is control scope on the server because it compiles client-supplied Rhai, so a keyed datahub refuses this tool at connect rather than one round trip later. ## The prompt [#the-prompt] > Walk this strategy forward over four anchored splits and tell me whether the edge holds out of > sample. The natural order is [`run_sweep`](/docs/ai/trader/tools/run_sweep) to find a candidate, then this to test it. [Walk-forward](/docs/trader/backtesting/walk-forward) is the concept page, [`vike-cli walkforward`](/docs/trader/reference/cli/walkforward) the human twin, and [Sweep and walk-forward](/docs/trader/tutorials/sweep-and-walk-forward) the worked run. # set_trading_state (/docs/ai/trader/tools/set_trading_state) `set_trading_state` sets the account trading state on a running `vike-tradehub` node. It is one of the seven order-write tools `vike-cli mcp` exposes, annotated `destructiveHint` in `tools/list`, and it needs `--node ` plus `VIKE_TRADEHUB_CONTROL_KEY` — taken from the process environment first, then from the credential store the daemon itself reads (`crates/vike-cli/src/cmd/mcp.rs`'s module header; `crates/vike-cli/src/cmd/nodekeys.rs`'s `CONTROL_KEY_ENV`). The tool's own description says both halves, and it says them because an agent reached for this tool to put a venue live. It moves an already-mounted account between `active`, `reducing` and `halted`; it cannot move a venue off the paper simulator, and neither can any other tool on this server. Which venues may go live is `policy.venues.` under `[venues]` in `/settings/policy.toml`, read *above* the credential read by `crates/vike-mount/src/lib.rs`'s `make_engine` and only ever able to refuse (`crates/vike-config/src/venue_mode.rs`'s `VenueMode::cap` is a minimum, never a maximum). It is a file a human edits on the box, and nothing reachable from here can write it. The remedy an agent invents when it does not know that — "set it in the node's environment and restart" — arms nothing and leaves the operator on paper with no error. [Arm one venue](/docs/trader/guides/arm-a-venue) and [`policy.toml`](/docs/trader/reference/settings/policy) are the pages that do answer it. ## Input schema [#input-schema] | Parameter | Type | Required | Notes | | --------------- | ------- | :------: | ---------------------------------------------------------------------------------------------------------------- | | `state` | string | yes | `active`, `reducing` or `halted` — lower case. Anything else is a tool error naming the three | | `reason` | string | no | The rationale 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 | The three spellings are the whole vocabulary: `crates/vike-cli/src/cmd/verbs.rs`'s `verb_from_tool_args` matches `"active"` / `"reducing"` / `"halted"` and returns `unknown state {other:?} (active | reducing | halted)` for everything else. ## What each state means [#what-each-state-means] The states are a strict ladder — `Halted` admits less than `Reducing`, which admits less than `Active` — enforced in the risk gate, not in this tool. `crates/vike-exec/src/risk.rs`'s `TradingState` is the definition and `RiskGate`'s `check` is where each one bites: * **`active`** — normal trading. * **`reducing`** — "only position-reducing orders allowed". An order that does not reduce is denied with the reason `reduce-only`. * **`halted`** — "no new orders (kill switch)". Denied with `halted`, with one deliberate exception: a *position-covered* reduce (`vike_model::is_covered_reduce` — side opposite the position, size within it) is still admitted, so a halt stops you opening risk without trapping you in it. ## A call, and what comes back [#a-call-and-what-comes-back] The transport is newline-delimited JSON-RPC 2.0 on stdin/stdout (`crates/vike-cli/src/cmd/mcp.rs` hand-rolls it — no SDK). One line in, one line out: ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"set_trading_state","arguments":{"state":"halted","reason":"feed gap on the primary venue"}}} ``` With no `confirm`, nothing is sent. `Server::call_tool`'s write arm returns `preview_of(...)`, whose `structuredContent` looks like this — the values below are from a server with **no** node configured, which is why the verdict is absent: ```json { "will_execute": false, "tool": "set_trading_state", "wire_command": { "SetTradingState": "Halted" }, "guardrail": { "within_limits": true, "note": "no order size to check for this verb" }, "node_verdict": null, "verified_by_node": false, "preview_token": "pv-1", "reason": "feed gap on the primary venue", "note": "PREVIEW ONLY — nothing was sent. ..." } ``` `wire_command` is pinned by `set_trading_state_preview_maps_the_wire_command` in the same file. The guardrail cell is `Guardrail::NoSize`: `verbs::guardrail_check` only sizes a `Submit`, so every other verb reports trivially within limits. `node_verdict` is the node's own dry-run and is `null` here only because there was no node address or control key to ask with; the `note` spells out the confirm rule, names the node's own gates as the enforcing ones, and — on this unverified path — says in words that the guardrail is a client-side estimate rather than an approval. **To execute, send the same arguments again with both `"confirm": true` and that `"preview_token"`.** Either field alone returns a fresh preview and moves nothing, which is not an error and is easy to mistake for success — read `will_execute`. The token fires once, expires after 60 seconds, and is bound to this exact command, so a preview of `halted` cannot be used to confirm `active`; [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) is the full rule set. ```json {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"set_trading_state","arguments":{"state":"halted","reason":"feed gap on the primary venue","confirm":true,"preview_token":"pv-1"}}} ``` `Server::execute` awaits the ticket that send returned and answers for *that* command — `outcome: "accepted"` with an empty `client_order_id` (this is an account-wide verb), `isError` on a refusal, or `outcome: "unknown"` after the two-second `ACK_WAIT` deadline, in which case the command may still execute and [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) is what settles it. ## Scope: every engine, not one venue [#scope-every-engine-not-one-venue] `crates/vike-core/src/runtime/mod.rs`'s `Command::SetTradingState` arm writes the primary engine's `trading_state` and then every extra engine's. One call moves the whole node, which is why the accepted response carries no client order id. It is also never size-capped. `crates/vike-tradehub/src/server.rs`'s `ControlLimits::vet` consumes a rate token on every command, but its notional ceiling applies only to the order-increasing verbs that carry a price; cancel, flatten, mass-cancel, market-exit and set-trading-state are risk-reducing and exempt by construction. `market_exit` and its mass-cancel are usable from a halted core with no precondition — the covered reduce a flatten mints is exactly the shape the gate admits under `Halted` (`crates/vike-exec/src/lanes.rs`'s `MarketExit`), with one residual: the gate re-takes that verdict against the **lot-rounded** size, so a position sitting off the lot grid rounds its own leg up past itself and that leg alone comes back denied with the reason `halted`. This is the reverse of the rule that held for the verb's whole life: the gate used to deny every order under `Halted`, so an operator had to un-halt first, re-arming the strategy that caused the halt in order to escape it. Reach for [`market_exit`](/docs/ai/trader/tools/market_exit), not for `set_trading_state active`. The file sentinel (`crates/vike-bridge-core/src/halt.rs`) is a *separate*, cross-process kill switch checked at the `ExecActor` submit boundary, and it exists precisely to work when the runtime is wedged. Setting the trading state back to `active` does not remove it. Either dead-man's switch under the default `cancel_all_and_halt` action engages both at once — the silence one (`crates/vike-core/src/runtime/deadman.rs`) and the connection one (`crates/vike-core/src/runtime/link_deadman.rs`), which is armed unless `policy.toml` writes `link_deadman_grace_ms = 0` — so clearing one leaves the other armed, and on a mount where the connection switch arms this file can appear with no operator having touched anything. The optional drawdown latch sets every engine to `Reducing` and never un-latches itself. A manual `set_trading_state active` re-arms it against the *same running high-water mark*, so a still underwater account latches again on the next closed-bar sweep — `CoreThread::sweep_drawdown_latch` in `crates/vike-core/src/runtime/watchdog.rs` states the rule. ## What is recorded, and how to see the result [#what-is-recorded-and-how-to-see-the-result] An accepted command is recorded under the kind `set_trading_state` with an empty coid (`crates/vike-tradehub/src/audit.rs`'s `record`). Any `reason` is trimmed client-side, sanitized server-side by `sanitize_reason` — control characters stripped, capped at `MAX_REASON_CHARS` — and never reaches the order, the core fold, the journal or a venue. The result is visible in the node's snapshot: `trading_state` at the top level and `venues[].trading_state` per venue. Concepts: [Halt](/docs/trader/concepts/halt), [Risk and arming](/docs/trader/concepts/risk-and-arming). > Halt trading on the node — reason: the primary feed has gone quiet and I want to look before > anything else goes out. # submit_order (/docs/ai/trader/tools/submit_order) `submit_order` is the write tool that opens risk. Everything else in the roster cancels, reduces or reports, with one exception — [`modify`](/docs/ai/trader/tools/modify) can raise a resting order's quantity, which is why the node sizes it against the same notional ceiling; this one puts a brand-new order on a venue. It is annotated `destructiveHint: true` and `idempotentHint: false`, and it needs `vike-cli mcp --node ` plus `VIKE_TRADEHUB_CONTROL_KEY`. ## Arguments [#arguments] | 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 | Read by the builder, though the advertised schema does not list it | | `reason` | string | no | Rationale for 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 raised in `verbs::verb_from_tool_args` before any command exists — `submit_order_missing_required_field_is_a_tool_error` pins it with a call that omits `symbol`. The order kinds and what each one means have their own [pages](/docs/trader/concepts/orders/). ## Two calls, and the second one carries two things [#two-calls-and-the-second-one-carries-two-things] **Call one previews.** `Server::call_tool` builds the wire command, mints a client order id, asks the node for its own dry-run, and returns `preview_of(...)` — `will_execute: false`, the fully resolved `wire_command`, the advisory `guardrail`, the `node_verdict` and whether it was obtained, the `reason` that would be recorded, a note, and the **`preview_token`** that confirms this exact command. With no node configured it opens no socket at all: `submit_order_without_confirm_only_previews_and_touches_no_network` proves a preview succeeds in that state, and checks the guardrail's notional is `50.0` for a limit buy of `0.5` at `100.0`. **Call two is the one that sends** — and it needs **both** `confirm: true` **and** that `preview_token`: ```json { "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.001, "confirm": true, "preview_token": "pv-1" } ``` The token fires once, expires after 60 seconds and is bound to the command it previewed; [Live node](/docs/ai/trader/trading#what-the-token-is-and-what-it-is-not) sets out all four rules and why the token is deliberately not a secret. Both conditions are read together — a token that arrives without `confirm: true`, and a `confirm` that arrives without a token, are the same case: the call returns a **fresh preview**, `will_execute: false`, a new token, and nothing on the wire. `confirm_alone_no_longer_executes_it_returns_a_preview` pins exactly that. So an agent that treats its second call as proof it traded, without reading `will_execute`, believes it holds an order it never placed. `confirm` is also read as a JSON boolean and nothing else, so `"true"`, `1` and `"yes"` fall through the same way. A bad token is the loud case: a tool error carrying the reason. There are three messages, not four — the store cannot tell an *unknown* token from an *already-used* one, because `take` removed the used one, so both come back as `unknown or already used`. Read that one as possibly-already-sent and settle it with `node_snapshot` rather than re-confirming. *Expired* and *mismatched* each have their own message. Confirming with a valid token and no node reaches the connection and fails there — a clean error, not a panic, and the fact that it got that far is what `a_preview_token_fires_at_most_once` uses to prove the token passed the gate. ## The client order id [#the-client-order-id] The node **refuses** a remote submit whose `client_order_id` is empty. `lower_command` says why: an empty one would let the runtime mint, which is fine in-process, but a remote peer then has no stable handle to cancel or dedup by. So `verbs::fill_client_order_id` mints one *before* the preview branch, and the preview shows a real id. The mint is `verbs::coid_minter` — the live core's own generator and wire form, `<8-hex-session>`. Its doc argues four properties: unique per order (the sequence increments), no collision across sessions (the prefix is drawn from OS entropy per process), typeable at 9–11 characters, and venue-safe against `^[A-Za-z0-9]{1,32}$`, the strictest common denominator across the big crypto venues. **The id in the preview is the id that gets sent.** A confirming call does mint a fresh one while rebuilding the command — the mint runs on every call — but the rebuilt command is only ever compared: `same_intent` excludes the `client_order_id` for exactly this reason, and the server then executes the **stored** command. That is a stronger property than comparing the ids would have been, because what reaches the venue is byte-for-byte what the preview displayed and the node dry-ran. The `client_order_id` in the accepted response is still the authoritative one to keep. (Passing the previewed id back explicitly is harmless and changes nothing — `same_intent` ignores the field either way.) The human `trade` REPL refuses a `--coid` outside that charset. This tool does not: `verbs::verb_from_tool_args` passes an agent-supplied `client_order_id` through unvalidated, and the node's `lower_command` requires only that it be non-empty. Re-sending the same id proves nothing and is not free: the core registry is coid-keyed, so a repeat leaves **one** row — but `gate_and_register` inserts over it rather than rejecting the duplicate, and `submit_order` calls `client.submit` both times, so on the default paper mount two orders rest. ## What checks the size [#what-checks-the-size] The preview's `guardrail` is advisory and computed on **your** machine (`verbs::guardrail_check`). `max_qty` comes from `VIKE_MAX_ORDER_QTY`, a local typo-catcher with no enforcing counterpart anywhere; `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 is server-side. `ControlLimits::vet` consumes a rate token on every command and then applies the notional ceiling to a submit that carries a price — and to a `modify` naming both a new qty and a new price — against the **node's** own `max_notional_per_order`, which is **unset by default**: `notional_reason` opens on that `Option`, so a node whose `policy.toml` names no ceiling skips this half entirely. Notional there is a magnitude — the check takes `.abs()` of every factor, because a site that absed the quantity only let a negative price produce a negative notional that could never trip the ceiling. Beneath it sits the core `RiskGate`. Neither is reachable from an agent, and the ceiling is a settings key: see [policy.toml](/docs/trader/reference/settings/policy) and [Risk and arming](/docs/trader/concepts/risk-and-arming). Arming is upstream of all of it. A venue with no credentials mounts as paper, and `policy.venues.` defaults to `paper` and can only ever refuse. ## Outcomes [#outcomes] `Server::execute` awaits the ticket that send returned and waits `ACK_WAIT`, two seconds — a deadline, not a sleep. Accepted returns `sent: true`, `outcome: "accepted"` and the authoritative `client_order_id`. A refusal is an error result carrying the node's reason. A timeout returns `outcome: "unknown"`; a dropped control connection is an error result saying the outcome is unknown and it may have executed. For both ambiguous cases, call [`node_snapshot`](/docs/ai/trader/tools/node_snapshot) **before** retrying. Then watch it: [order lifecycle](/docs/trader/concepts/order-lifecycle) describes the states the order now moves through, and [`cancel_order`](/docs/ai/trader/tools/cancel_order), [`modify`](/docs/ai/trader/tools/modify) and [`flatten`](/docs/ai/trader/tools/flatten) are what act on it. > Preview a limit buy of 0.5 BTCUSDT at 60,000 on binance, then place it if the guardrail is clean. # validate_strategy (/docs/ai/trader/tools/validate_strategy) Compiles a Rhai strategy and answers whether it compiled. Compile *is* validation here: there is no separate linter. Entirely offline — no `--addr`, no `--node`, no key, no socket. ## Input [#input] | Field | Type | Required | Meaning | | -------- | ------ | :------: | -------------------- | | `script` | string | yes | Rhai strategy source | The argument is `script`, matching this server's sibling `discover_params`. A missing one is the only `isError` this tool produces, and its message names the argument (`validate_strategy_missing_script_arg_is_a_tool_error`). ```json {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"validate_strategy","arguments":{ "script": "let fast = param(\"fast\", 5.0);\nfn on_bar() {}" }}} ``` ## What comes back [#what-comes-back] Two shapes, and both are successes: ```json { "ok": true } ``` ```json { "ok": false, "error": "…the compiler's own message…" } ``` A bad script is an **answer**, not a tool failure — the agent reads the error and fixes the script. `validate_strategy_answers_ok_and_compile_errors_offline` pins both halves, including that `ok: false` arrives with `isError: false`. ## What the check actually is [#what-the-check-actually-is] `tool_validate_strategy` goes through `vike_script::discover_params`, the broker-free public compile path. That function compiles the source and then runs its **top level exactly once**, which is the same error surface as mounting the strategy for real: `RhaiStrategy::compile` does the same one-time top-level run, and the top level is where `param(name, default)` is called. So the check covers the grammar, and it covers everything a script does outside a function. Rhai resolves a registered function when the line **runs**, not at compile time, and this path runs only the top level. Every call a strategy makes sits inside `fn on_bar()` and is therefore unchecked. Three mistakes land in that blind spot: an unbound **name** (a typo, or any indicator the host does not bind), a wrong **arity**, and a wrong **argument type**. Name and arity always raise `ErrorFunctionNotFound`, and so does a mistyped argument to an **order verb** — `register_verbs` binds `market` as `(i64, f64)`, rhai hashes each argument's `TypeId` and performs no int-to-float coercion for a registered function, so `market(1, 1)` misses exactly as hard as a misspelling. An **indicator** 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. Each of them errors on every bar, `RhaiStrategy`'s hook runner swallows it fail-safe (zero orders that bar), and after 10 consecutive errors the strategy disables itself. It looks mounted and silently never trades. That hazard is demonstrated rather than argued in the product tree: `an_unbound_call_compiles_and_then_reaches_the_broker_with_nothing` takes the shipped SMA-cross template, rewrites its `sma(` calls to a name nothing binds, and shows that it still compiles, still reports its `param()` knobs, and reaches the broker with nothing over 80 rising bars. ## So what does close the gap [#so-what-does-close-the-gap] Three things, in order of cost: 1. **Check every indicator name against [`list_indicators`](/docs/ai/trader/tools/list_indicators)**, not against memory. That tool lists the host-bound callable set, which is not the same as the indicator registry. 2. **Read the knobs back with [`discover_params`](/docs/ai/trader/tools/discover_params).** It shares this compile path, so it confirms the top level ran and tells you which names a `[sweep]` may vary. Note the deliberate asymmetry: there, a compile error *is* a tool error, because there are no parameters to report. 3. **Run it.** [`run_backtest`](/docs/ai/trader/tools/run_backtest) over a short slice, then look at `n_trades`. Zero trades with a populated `zero_trade` diagnosis is the shape a self-disabled strategy leaves behind, and that field exists precisely so the answer is not a table of zeros. ## The prompt [#the-prompt] > Does this strategy compile? If not, fix it and try again. An agent can loop on this tool freely — it opens nothing and costs nothing but its own turn. [Scripting](/docs/trader/scripting) is the language surface, [the cheatsheet](/docs/trader/scripting/cheatsheet) every host function and order verb in one page, and [Rhai strategy from scratch](/docs/trader/tutorials/rhai-strategy-from-scratch) the worked build. # Polymarket archive (/docs/endpoints/data/archive) The archive is the one namespace with no JSON envelope and no OpenAPI operations. It is **three paths** on `https://data.vike.io`, each serving files straight off disk. Two are open; the third checks your credential before it opens anything. That shape decides what these pages can tell you. There are no query parameters to table, no JSON envelope and no response schema: the request is a path, and the answer is a status code and some bytes. What varies is the **path**, and the path is a Hive partition. Every archive request is still authenticated and still recorded as a download against your key. It just no longer counts against the hourly ceiling, since 2026-09-02. Requests were the wrong unit: the credential is checked once per HTTP request, and a Parquet reader pointed at a URL issues one Range request per column chunk — **one 1.26 GB partition measured 20,237 of them**. See [Rate limits](/docs/data/rate-limits), which is where that counter is documented. This is not licence to read remotely. It is still thousands of round trips for one file, and [Downloading](/docs/data/archive/downloading) is the page that shows the fast way. There is no second spelling of these paths. As of 2026-09-03 every URI that is not one of the three below returns **404** — including anything that drops the `/archive/` prefix — and the gated partitions are reachable only under that prefix, with a credential. Measured below, unauthenticated. ## Free — no credential [#free--no-credential] Neither of these two paths is gated, so a request to them validates nothing, records no usage and consumes nothing. They are the surface a prospective customer can read before buying. | Path | What it is | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | [`/archive/manifest.json`](/docs/endpoints/data/archive/manifest-json) | The index of everything in the archive, regenerated daily. It needs **no key**. | | [`/archive/samples/`](/docs/endpoints/data/archive/samples) | A real slice of the full archive — one complete crypto up/down 5-minute market, both outcome tokens, open through resolution, all three streams. | ## Key-gated — the full archive [#key-gated--the-full-archive] | Path | What it is | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | [`/archive/{partition path}`](/docs/endpoints/data/archive/partition-path) | One prefix covering the whole archive — two Hive partitionings of the same data behind one path, not two endpoints. | ## Status codes, measured [#status-codes-measured] Every row below was probed against the live host on **2026-09-03 with no credential**. They are the three behaviours worth internalising: free means free, gated means 401 rather than 404, and everything outside the three documented paths is gone. Re-run the whole table yourself — it needs no credential, and status codes are the only thing being read, so nothing is downloaded: ```sh title="Re-run the table" for p in \ /archive/manifest.json \ /archive/samples/ \ /archive/samples/README.txt \ /archive/samples \ /archive/ \ "/archive/venue=polymarket/date=2026-09-01/trades.parquet" \ /archive \ /manifest.json ; do printf '%-62s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "https://data.vike.io$p")" done ``` | Request | Status | | ------------------------------------------------------------------- | ------- | | `/archive/manifest.json` | **200** | | `/archive/samples/` | **200** | | `/archive/samples/README.txt` | **200** | | `/archive/samples` — no trailing slash | **401** | | `/archive/` | **401** | | `/archive/venue=polymarket/date=2026-09-01/trades.parquet` | **401** | | `/archive` — no trailing slash | **404** | | `/manifest.json` — prefix dropped | **404** | | `/samples/README.txt` — prefix dropped | **404** | | `/venue=polymarket/date=2026-09-01/trades.parquet` — prefix dropped | **404** | **401 and 404 mean different things here.** A 401 says the path resolved and the credential did not; a 404 says nothing is routed there at all. A 404 on an `/archive/`-prefixed path you believe exists is a path bug — check it against `dates[]` or `families[]` in the manifest. ## Documented elsewhere [#documented-elsewhere] These pages are the routing and the gate. The archive's substance has its own pages, and they are not repeated here: * **[Bulk archive](/docs/data/archive)** — the three streams, both Hive layouts, per-stream column schemas with their two decoding traps, current coverage, and the free samples. * **[Downloading](/docs/data/archive/downloading)** — why a remote Parquet read is thousands of requests, worked `curl` and Python that are one, and how to fetch less by choosing a narrower file instead of a byte range. * **[Polymarket](/docs/endpoints/data/polymarket)** — `/v1/polymarket/archive/datasets` and `/v1/polymarket/archive/url`, the two JSON endpoints that index exactly the files these pages serve. They are filed there because that is where they are routed, not because they are about something else. * **[Rate limits](/docs/data/rate-limits)** — the one hourly counter, what it counts, and the archive's exclusion from it. # /archive/manifest.json (/docs/endpoints/data/archive/manifest-json) ``` GET https://data.vike.io/archive/manifest.json ``` The index of everything in the archive, regenerated daily: coverage, both partitioning patterns, the inline column schemas, the auth scheme, the licence, and one entry per partition carrying byte size and row count. It needs **no key**. Fetch it first and enumerate it — it is what is true today, where any figure written into a doc page is a snapshot. ```sh title="cURL" curl "https://data.vike.io/archive/manifest.json" -o manifest.json ``` The missing `-H` is deliberate, not an omission: this is one of the two keyless paths, so the request validates nothing and records nothing. The live response, fetched **2026-09-03**, carries three elisions, marked inline and counted under the fence. ```json title="Example response (elided)" { "venue": "polymarket", "generated_at": "2026-09-03T00:37:42Z", "license": { "name": "CC BY 4.0", "url": "https://creativecommons.org/licenses/by/4.0/", "attribution": "Polymarket L2 archive (c) Vike (vike.io/data), CC BY 4.0", "note": "Applies to the DATA files this manifest indexes. It grants no rights to any Vike source code." }, "partitioning": "venue=polymarket/date=YYYY-MM-DD/{book_events,trades,l1_quotes}.parquet", "note": "Full L2 (book_events) + taker tape (trades) + derived top-of-book (l1_quotes) for the whole Polymarket universe (crypto up/down + sports + politics markets), one UTC day per partition.", "schema": { "book_events": [ /* 16 columns, elided */ ], "trades": [ { "name": "token_id", "type": "String" }, { "name": "condition_id", "type": "String" }, { "name": "ts", "type": "Int64" }, { "name": "local_ts", "type": "Int64" }, { "name": "seq", "type": "UInt64" }, { "name": "price", "type": "Float64" }, { "name": "size", "type": "Float64" }, { "name": "side", "type": "String" } ], "l1_quotes": [ /* 8 columns, elided */ ] }, "auth": { "scheme": "header", "header": "X-API-Key", "how_to_get_a_key": "Request access via vike.io — the same account/API-key system that gates data.vike.io/v1 and data.vike.io/mcp." }, "range_requests": true, "free_sample": { "path": "/archive/samples/", "readme": "/archive/samples/README.txt", "note": "One full crypto up/down 5-minute market (both outcome tokens, open through resolution), regenerated daily — no key required." }, "dates": [ { "date": "2026-09-02", "streams": { "book_events": { "rows": 306994531, "bytes": 4799571940 }, "trades": { "rows": 1006555, "bytes": 12314277 }, "l1_quotes": { "rows": 36596619, "bytes": 373583589 } } }, { "date": "2026-09-01", "streams": { "book_events": { "rows": 307245726, "bytes": 4826341757 }, "trades": { "rows": 1100045, "bytes": 13459739 }, "l1_quotes": { "rows": 32065417, "bytes": 335196889 } } } /* 38 more dates, newest first, ending 2026-07-25 */ ], "family_partitioning": "venue=polymarket/asset=/tenor=/date=YYYY-MM-DD/{book_events,trades,l1_quotes}.parquet", "family_partitioning_note": "ADDITIVE alternative layout (poly_export_family.sh) grouping the crypto up/down families (asset x tenor, e.g. asset=btc/tenor=5m) into their own small files so a single-family customer does not download the whole token-sorted date= file. asset=other/tenor=other is the catch-all for every market that is not a recognized up/down family (sports, politics, one-off events) — never silently dropped. Same schema as the date= layout (see `schema` above); not every (asset,tenor,date) is guaranteed present for every stream (a family with zero rows in a stream that day has no file for it).", "families": [ { "asset": "xrp", "tenor": "5m", "date": "2026-09-02", "streams": { "book_events": { "rows": 8531385, "bytes": 104536361 }, "trades": { "rows": 33332, "bytes": 555993 }, "l1_quotes": { "rows": 1699187, "bytes": 11729583 } } }, { "asset": "xrp", "tenor": "5m", "date": "2026-09-01", "streams": { "book_events": { "rows": 7852908, "bytes": 99223946 }, "trades": { "rows": 33015, "bytes": 544739 }, "l1_quotes": { "rows": 986897, "bytes": 7096420 } } } /* 658 more families */ ] } ``` **What was elided, exactly.** The response is **262,424 bytes**. Every scalar and every object above is verbatim; three arrays are cut, and nothing else is. `schema.book_events` (16 columns) and `schema.l1_quotes` (8) are dropped — `schema.trades` is shown whole as the shape exemplar, and the full column lists are on [Bulk archive](/docs/data/archive#column-schemas). `dates` carries **40** entries and `families` **660**; two of each are shown, in the order the response returns them, which is newest first. ⚠ **The two `type` vocabularies are ClickHouse's, not Parquet's** — visible above as `Int64` and `Float64` on `trades`, and as `Decimal(9, 4)`, `LowCardinality(String)` and `Enum8('book' = 1, …)` on the two elided streams. They describe the table the file was exported from. What a Parquet reader hands back is the physical type, and the gap between the two is the decoding trap that [Bulk archive](/docs/data/archive#column-schemas) works through. The tail of `dates[]` is a different shape from its head, which is why the head alone would mislead — the oldest partition in the same response: ```json title="The oldest dates[] row" { "date": "2026-07-25", "streams": { "book_events": { "rows": null, "bytes": 5235801848 }, "trades": { "rows": null, "bytes": 12139041 }, "l1_quotes": { "rows": null, "bytes": 357730559 } } } ``` **Parameters: none.** No query string is read, and no header changes the response. The path is matched *exactly*, so `manifest.json` is the only keyless name under `/archive/`: any other name in that directory is gated like the rest of the full archive, and answers **401** without a credential. Top-level keys, read from the live response fetched 2026-09-03: | Key | What it holds | | ------------------------------------------------- | ------------------------------------------------------------------------ | | `generated_at` | UTC timestamp of this regeneration. | | `venue` | `polymarket`. | | `note` | One sentence on what the three streams are. | | `partitioning` | The `date=` pattern string. | | `family_partitioning`, `family_partitioning_note` | The additive `asset=`/`tenor=` pattern and its caveats. | | `dates` | One row per UTC day, each with a `streams` object keyed by stream name. | | `families` | One row per (`asset`, `tenor`, `date`), same `streams` shape. | | `schema` | Column types per stream — `book_events`, `trades`, `l1_quotes`. | | `auth` | `scheme`, `header` (`X-API-Key`) and how to get a key. | | `free_sample` | Paths to `/archive/samples/` and its `README.txt`. | | `license` | CC BY 4.0 with its attribution string, covering the **data files only**. | | `range_requests` | `true`. | A `dates[]` row, verbatim from that response: ```json title="One dates[] row, verbatim" {"date":"2026-09-02","streams":{ "book_events":{"rows":306994531,"bytes":4799571940}, "trades":{"rows":1006555,"bytes":12314277}, "l1_quotes":{"rows":36596619,"bytes":373583589}}} ``` ⚠ **`rows` is optional, `bytes` is not.** The three oldest partitions carry a byte size and a `null` row count — confirmed in today's manifest for 2026-07-25, 2026-07-26 and 2026-07-27. Code that does arithmetic on `rows` without a null check breaks on the earliest dates and nowhere else, which is the hardest kind of bug to find. **Discovering partitions programmatically.** The manifest is not the only index. Two JSON endpoints answer the same question over HTTP, with filters and paging — [`/v1/polymarket/archive/datasets`](/docs/endpoints/data/polymarket) enumerates partitions across both layouts, each row carrying the absolute download URL beside that file's byte and row counts, and `/v1/polymarket/archive/url` resolves one already-known partition to the same information. They are routed under the **polymarket** namespace rather than under `/archive/`, because the archive carries only Polymarket data. There is no `/v1/archive/...` alias — that spelling is routed nowhere and answers 404. ⚠ **Those two are ordinary API calls.** They need a credential and they *do* spend the hourly budget that an archive download does not — see [Rate limits](/docs/data/rate-limits). This manifest remains the keyless way to get the whole index in one request; the discovery endpoints are what you want when you would rather filter server-side than parse 250 KB of JSON. # /archive/{partition path} (/docs/endpoints/data/archive/partition-path) ``` GET https://data.vike.io/archive/venue=polymarket/date={YYYY-MM-DD}/{stream}.parquet GET https://data.vike.io/archive/venue=polymarket/asset={asset}/tenor={tenor}/date={YYYY-MM-DD}/{stream}.parquet ``` One path prefix covering the whole archive. The two path shapes above are **two Hive partitionings of the same data behind one path**, not two endpoints — full detail of both, and which dates each covers, is on [Bulk archive](/docs/data/archive#two-layouts). **There is no JSON response to show you.** The body is a binary Apache Parquet file — the one that command downloads is 13,459,739 bytes. So the example below asks for the first 2 KB of the next day's partition instead of the whole of it, which is enough to prove the route, the gate and the file type without moving the file: ```sh title="cURL — probe with a 2 KB range" curl -H "X-API-Key: $VIKE_API_KEY" \ -r 0-2047 -D - -o /dev/null \ "https://data.vike.io/archive/venue=polymarket/date=2026-09-02/trades.parquet" ``` Response headers, verbatim, 2026-09-03: ```http title="Response headers" HTTP/1.1 206 Partial Content Content-Type: application/octet-stream Content-Length: 2048 Content-Range: bytes 0-2047/12314277 Last-Modified: Thu, 03 Sep 2026 00:32:06 GMT ETag: "6a98c006-bbe6a5" Cache-Control: private, no-store Server: cloudflare cf-cache-status: DYNAMIC ``` The first four bytes of that body are `50 41 52 31` — the ASCII `PAR1` every Parquet file opens and closes with. The rest is zstd-compressed column data and is not readable as text. Three of those headers are worth reading as confirmations rather than trivia. **`206` with a `Content-Range` is the `Range` row of the table below, working** — the server served 2,048 bytes of a file it told you is 12,314,277. That total is also the cross-check on [`manifest.json`](/docs/endpoints/data/archive/manifest-json): its `dates` entry for `2026-09-02` reports the `trades` stream at 1,006,555 rows and 12,314,277 bytes — the same total — so a size read from the manifest is the size you will actually be billed bandwidth for. And **`cf-cache-status: DYNAMIC` beside `Cache-Control: private, no-store`** is the third bullet below holding: Cloudflare saw the response and declined to cache it. Drop the credential and the same URL never reaches the file — the request is refused before the file is opened. The status is **401 Authorization Required** and the body is a plain HTML error page from the edge, not a JSON envelope: ```html title="Refused without a credential (401)" 401 Authorization Required

401 Authorization Required

``` ⚠ **That 401 body is HTML, not the JSON error envelope the `/v1` endpoints return.** A client that assumes every failure parses as JSON breaks here — see [Errors](/docs/data/errors). It is also indistinguishable from the 401 a path that does not exist returns: probed keylessly on 2026-09-03, the real partition above and an invented `date=1999-01-01/nosuchstream.parquet` both answered **401** with byte-identical bodies. Without a credential you cannot tell a typo from a refusal, because the gate runs before the lookup. There are no query parameters. What you can vary is the path and a small set of headers: ```sh title="cURL — download the partition" 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" ``` The API key. HTTP header names are case-insensitive; the REST spec spells it `X-API-KEY` and the manifest `X-API-Key` — same header, same key. OAuth 2.1 access token. The bearer is tried **first** , then the `X-API-Key` header. Both reach the same account — see [Authentication](/docs/data/authentication) . Supported: responses carry `Accept-Ranges: bytes` , and the manifest advertises `range_requests: true` . It is there so an interrupted download can resume, not so a query engine can page a remote file. `polymarket` . The only venue in the archive today. One UTC day, `YYYY-MM-DD` . Enumerate `dates[]` in the manifest; do not probe for dates. \| `asset` | path | family layout only | `btc`, `eth`, `sol`, `xrp`, `doge`, `bnb`, `hype`, `other` — read from today's manifest, which is the authority rather than this row. | \| `tenor` | path | family layout only | `5m`, `15m`, `4h`, `other`, same caveat. | \| `{stream}` | path | yes | `book_events`, `trades` or `l1_quotes`. Schemas differ per stream — [Bulk archive](/docs/data/archive#column-schemas). | ¹ One of the two credentials, not both. **A missing file is a legitimate answer in the family layout.** A family with zero rows in a stream on a given day has no file for that stream, and the family layout does not cover every date the `date=` layout does. Enumerate `families[]` rather than assuming. Three properties of this location worth knowing before you point a tool at it: * **Responses carry `Cache-Control: private, no-store`**, on every response. An authenticated file must never be cached at the edge — a cached copy would bypass both the gate and the usage record — so do not expect a CDN to serve a repeat download for you. * **A directory URL under `/archive/` returns a listing** rather than a 403, once you present a credential. The listing is HTML. * **Throughput is capped at 50 MB/s per connection.** It is a bandwidth bound, not a request bound; parallel whole-file downloads are the supported way to go faster, and cost one recorded download each. ⚠ **This path never returns a rate-limit 429**: archive downloads do not draw on the hourly budget at all. A refusal here is a credential problem, not a budget one — see [Rate limits](/docs/data/rate-limits) and [Errors](/docs/data/errors). # /archive/samples/ (/docs/endpoints/data/archive/samples) ``` GET https://data.vike.io/archive/samples/ GET https://data.vike.io/archive/samples/{file} ``` A real slice of the full archive — one complete crypto up/down 5-minute market, both outcome tokens, open through resolution, all three streams — regenerated daily and served free. It is the right place to write and debug a parser: same column schema as the key-gated files, at megabytes rather than gigabytes. Both forms are keyless — this path is not gated, so neither request below sends a credential, spends budget or records usage. ```sh title="cURL — list the directory" curl "https://data.vike.io/archive/samples/" ``` The response is a directory listing page, **HTML rather than JSON**. Verbatim, fetched 2026-09-03: ```html title="Directory listing (HTML)" Index of /archive/samples/

Index of /archive/samples/



``` There is no JSON form of this listing. Parsing it means parsing HTML — which is a reason to enumerate the archive through `manifest.json` or the two [discovery endpoints](/docs/endpoints/data/polymarket) instead, and to treat this path as the place you *download* from rather than the place you *index* from. The second form fetches one file by name. `README.txt` is the one to read first: it names the market the current sample was cut from, which is the fact the listing above cannot tell you. ```sh title="cURL — fetch one file" curl "https://data.vike.io/archive/samples/README.txt" ``` Verbatim, same fetch — 1,376 bytes, `Content-Type: text/plain`: ```text title="README.txt" Polymarket L2 archive — FREE evaluation sample ================================================ This is a real, complete slice of the paid archive (not a synthetic or truncated demo): one full crypto up/down 5-minute market — the busiest one of its kind (by book_events row count) on the sample date below — with all three streams for BOTH outcome tokens (up + down) across that market's entire lifecycle, open through resolution. market slug: btc-updown-5m-1788358500 condition_id: 0xbf68516768c5732dcc70a342c96eab9ecee9e1b423897070d1ad5385e34bacd7 sample date: 2026-09-02 (UTC) generated at: 2026-09-03T00:32:22Z Files (Hive/Parquet, same column schema as the paid archive — see /archive/manifest.json for the exact schema and the full list of available dates): book_events.parquet 395640 rows, 6095263 bytes — full L2: snapshots + price_change deltas + trades + status markers trades.parquet 3316 rows, 55905 bytes — taker tape only l1_quotes.parquet 54726 rows, 631242 bytes — derived top-of-book bid/ask This sample is regenerated daily from a real recent day, not a fixed historical snapshot. The paid archive covers the FULL Polymarket universe (crypto up/down + sports + politics markets) for every date listed in /archive/manifest.json. Auth: an X-API-Key header; the manifest explains how to request a key. ``` ⚠ **Every specific in those two responses is a day old at most.** The slug, the `condition_id`, the sample date and all six sizes are regenerated nightly — the capture above ran at 08:17 UTC against files stamped 00:32 the same morning. They are shown so you can see the *shape* of what you get; read the live `README.txt` for what you actually have. ⚠ **"Paid archive" in that file is stale wording.** The full archive is free: it needs a free API key, and downloading it does not spend the key's hourly budget ([Rate limits](/docs/data/rate-limits)). The capture above is kept verbatim. **Parameters: none.** The directory URL returns the listing as **HTML**, not JSON; the file URLs return the bytes. Today's listing: ``` README.txt 1,376 bytes book_events.parquet 6,095,263 bytes l1_quotes.parquet 631,242 bytes trades.parquet 55,905 bytes ``` The sizes and the market change daily — `README.txt` names the market and date of the current one. Do not pin them. ⚠ **The trailing slash is load-bearing.** The keyless path is `/archive/samples/`, slash included; without it the request is treated as an ordinary gated `/archive/` path instead. Measured today, unauthenticated: `/archive/samples/` → **200**, `/archive/samples` → **401**. The same one-character mistake explains a 401 on a resource that is documented as free. # /assets/hourly (/docs/endpoints/data/hyperliquid/assets-hourly) ``` GET https://data.vike.io/v1/hyperliquid/assets/hourly ``` Hourly close **plus** open interest, funding rate and premium, for many assets at once. This is the join a research walk actually consumes; it exists so consumers stop stitching the price series and the market-state series together by hand. Truncated: `rows` holds **96** rows (2 assets x 48 hours); the first 4 are shown. They are the positional arrays the trap below describes, ordered by `columns`. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/assets/hourly?symbols=BTC,ETH&days=2" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Comma-separated perp tickers, e.g. `BTC,ETH,SOL` . Max 64 Window length back from `end` . Default 120, range 1–400 ISO-8601 UTC or epoch s/ms, inclusive. **Overrides `days`** ISO-8601 UTC or epoch s/ms, exclusive. Defaults to now ```json title="Example response" { "assets": [ "BTC", "ETH" ], "tz": "UTC", "source": "1h fills rollup + hourly asset summary", "start": 1788250014, "end": 1788422814, "columns": [ "ts", "asset", "close", "open_interest", "funding_rate", "premium" ], "rows": [ [ 1788253200, "BTC", 77899.0, 38732.56691000003, 0.000012500000000000004, -0.00005489032500000001 ], [ 1788256800, "BTC", 78024.0, 38985.63194500007, 0.000012500000000000004, -0.000063838675 ], [ 1788260400, "BTC", 78094.0, 39051.50362166676, 0.000012500000000000004, -0.00007857194999999999 ], [ 1788264000, "BTC", 78000.0, 39224.664336666705, 0.000012500000000000004, -0.00006519585833333333 ] ] } ``` ⚠ **Rows are arrays, not objects**, ordered by the response's `columns` field: `ts`, `asset`, `close`, `open_interest`, `funding_rate`, `premium`. A 120-day 10-asset window is 28,800 rows, and repeating six key names on each would triple the payload for nothing. This is a bulk read, not a browsing endpoint. ⚠ **Nulls stay null.** The join is LEFT from the price side: a missing market-state row arrives as `null`, never as `0.0`, because an open interest of zero is a real reading and substituting it for "we did not sample this hour" turns a gap into a data point. Carry it through as a NaN. ⚠ **There is no cursor — an oversized request is refused, not paged.** The ceiling is 256,000 rows (64 assets × 4,000 hours). Half a training window silently returned is worse than a refusal. `close` comes from the fills rollup, not from the hourly summary's own OHLC grid. That grid is built from a mid price sampled every 300s — 12 samples an hour, a midpoint nobody transacted at — and a backtest priced off it books fills at prices that never existed. Only `open_interest`, `funding_rate` and `premium`, which have no fills-side equivalent, come from the summary. # /builders/{builder}/fills (/docs/endpoints/data/hyperliquid/builders-builder-fills) ``` GET https://data.vike.io/v1/hyperliquid/builders/{builder}/fills ``` Raw builder-routed fills, newest first. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/builders/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/fills?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Builder address HL coin ticker `buy` \| `sell` ISO-8601 UTC bounds Default 100, max 5000 Keyset cursor ```json title="Example response" { "builder": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", "fills": [ { "ts": "2026-09-03T08:06:53.487000+00:00", "wallet": "0xb52271dd584fbfcae3f0a051abaaf81797a0f303", "asset": "xyz:SKHX", "dex_name": "XYZ", "side": "buy", "size_token": 0.012, "price": 1179.3, "size_usd": 14.1516, "fee_usd": 0.007075, "deployer_fee_usd": 0.00055, "fee": 0.008297, "fee_token": "USDC", "closed_pnl": 0.0, "crossed": true, "order_id": 534782081176, "trade_id": 460836386307183 }, { "ts": "2026-09-03T08:06:48.794000+00:00", "wallet": "0xb2761bd82613b5bab1b2925b82b94cc8f3e17581", "asset": "XRP", "dex_name": "HL", "side": "sell", "size_token": 2748.0, "price": 1.3786, "size_usd": 3788.3928, "fee_usd": 1.894196, "deployer_fee_usd": 0.0, "fee": 3.598972, "fee_token": "USDC", "closed_pnl": 27.111768, "crossed": true, "order_id": 534782009091, "trade_id": 1000357728267580 }, { "ts": "2026-09-03T08:06:48.794000+00:00", "wallet": "0xb2761bd82613b5bab1b2925b82b94cc8f3e17581", "asset": "XRP", "dex_name": "HL", "side": "sell", "size_token": 1613.0, "price": 1.3786, "size_usd": 2223.6818000000003, "fee_usd": 1.11184, "deployer_fee_usd": 0.0, "fee": 2.112496, "fee_token": "USDC", "closed_pnl": 15.913858, "crossed": true, "order_id": 534782009091, "trade_id": 682445774914906 } ], "nextCursor": "eyJpIjo2ODI0NDU3NzQ5MTQ5MDYsInQiOiIyMDI2LTA5LTAzIDA4OjA2OjQ4Ljc5NCIsInciOiIweGIyNzYxYmQ4MjYxM2I1YmFiMWIyOTI1YjgyYjk0Y2M4ZjNlMTc1ODEifQ" } ``` ⚠ **`fee` ships raw here, beside its own `fee_token`** — unlike every aggregate in this namespace. 0.95% of fills pay in a non-dollar token, so summing that column client-side without grouping by `fee_token` produces nonsense. `fee_usd` (the builder's slice) is dollars on every row. # /builders/{builder}/series (/docs/endpoints/data/hyperliquid/builders-builder-series) ``` GET https://data.vike.io/v1/hyperliquid/builders/{builder}/series ``` Fee, volume, users and fills per bucket for one builder, newest first. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/builders/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/series?bucket=1d&limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Builder address `1h` \| `1d` . Default `1d` ISO-8601 UTC bounds Default 500, max 2000 Keyset cursor ```json title="Example response" { "builder": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", "bucket": "1d", "window": { "start": "2026-08-31T08:06:57.246000+00:00", "end": "2026-09-03T08:06:57.246000+00:00" }, "series": [ { "ts": "2026-09-03T00:00:00+00:00", "fee_usd": 7585.101829000003, "volume_usd": 15170213.438114015, "share_volume_usd": 15170213.438114015, "fills": 10814, "users": 1560, "revenue_per_user": 4.862244762179489, "fee_rate_bps": 4.9999967765404065, "fee_usd_node": 7585.101829000003, "trader_fee_usd": 13297.350062000005, "builder_routed_deployer_fee_usd": 281.00174499999997, "closed_pnl_usd": 10536.69776000002, "crossed_fills": 10476, "taker_pct": 96.87442204549657 }, { "ts": "2026-09-02T00:00:00+00:00", "fee_usd": 26885.466125999978, "volume_usd": 53770969.339224845, "share_volume_usd": 53770969.339224845, "fills": 40543, "users": 3854, "revenue_per_user": 6.975990172807467, "fee_rate_bps": 4.999996551370996, "fee_usd_node": 26885.466125999978, "trader_fee_usd": 46489.33583100012, "builder_routed_deployer_fee_usd": 1543.7487520000045, "closed_pnl_usd": 32176.18408099984, "crossed_fills": 40283, "taker_pct": 99.35870557186198 }, { "ts": "2026-09-01T00:00:00+00:00", "fee_usd": 46430.14683999935, "volume_usd": 92850044.21443081, "share_volume_usd": 92850044.21443081, "fills": 46510, "users": 4322, "revenue_per_user": 10.742745682554222, "fee_rate_bps": 5.000551936493656, "fee_usd_node": 46430.14683999935, "trader_fee_usd": 76360.59171799968, "builder_routed_deployer_fee_usd": 1868.793809000012, "closed_pnl_usd": 56504.743095999846, "crossed_fills": 45875, "taker_pct": 98.6347022145775 } ], "nextCursor": "eyJ0IjoiMjAyNi0wOS0wMSAwMDowMDowMC4wMDAifQ" } ``` ⚠ **`users` is a distinct-wallet count per bucket and is not additive.** Summing the daily column double-counts anyone who traded on two days. A period total comes from [`/builders/{builder}`](/docs/endpoints/data/hyperliquid/builders-builder) over that period. # /builders/{builder}/users (/docs/endpoints/data/hyperliquid/builders-builder-users) ``` GET https://data.vike.io/v1/hyperliquid/builders/{builder}/users ``` The wallets routing through one builder, ranked. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/builders/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/users?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Builder address `1d` \| `7d` \| `30d` \| `all` . Default `30d` `volume` \| `fees` \| `pnl` . Default `volume` `asc` \| `desc` . Default `desc` Default 100, max 1000 Keyset cursor ```json title="Example response" { "builder": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", "window": { "window": "30d", "start": "2026-08-04T08:06:57.246000+00:00", "end": "2026-09-03T08:06:57.246000+00:00" }, "sort": "volume", "order": "desc", "users": [ { "wallet": "0xe1b02144c101e4f6c41bc746f854214404efa23d", "rank": 1, "assets": 1, "fee_usd": 58025.66724299987, "volume_usd": 116051339.95361994, "share_volume_usd": 116051339.95361994, "fills": 6401, "fee_rate_bps": 4.999999764430975, "fee_usd_node": 58025.66724299987, "trader_fee_usd": 96609.87647999988, "builder_routed_deployer_fee_usd": 0.0, "closed_pnl_usd": -16109.553342999967, "active_days": 30, "crossed_fills": 6238, "taker_pct": 97.45352288704889, "first_seen": "2026-08-04T10:39:34.604000+00:00", "last_seen": "2026-09-02T19:18:12.778000+00:00" }, { "wallet": "0xff573cc8d53d6ca48134c23da75df1984ff28c24", "rank": 2, "assets": 11, "fee_usd": 32611.176251000008, "volume_usd": 65043708.12699884, "share_volume_usd": 65043708.12699884, "fills": 13193, "fee_rate_bps": 5.013732640723094, "fee_usd_node": 32611.176251000008, "trader_fee_usd": 56593.81896899996, "builder_routed_deployer_fee_usd": 29.69778300000001, "closed_pnl_usd": 1960.3512290000253, "active_days": 17, "crossed_fills": 13193, "taker_pct": 100.0, "first_seen": "2026-08-04T08:10:00.798000+00:00", "last_seen": "2026-08-20T09:47:11.594000+00:00" }, { "wallet": "0x30381168675a9c97dbeb108b8cde33aba434be4f", "rank": 3, "assets": 20, "fee_usd": 30719.542745999996, "volume_usd": 61419224.695899084, "share_volume_usd": 61419224.695899084, "fills": 5981, "fee_rate_bps": 5.001616822436236, "fee_usd_node": 30719.542745999996, "trader_fee_usd": 54596.672999999966, "builder_routed_deployer_fee_usd": 547.0871540000002, "closed_pnl_usd": 57038.65442500002, "active_days": 19, "crossed_fills": 5981, "taker_pct": 100.0, "first_seen": "2026-08-04T09:14:37.068000+00:00", "last_seen": "2026-08-23T03:42:46.452000+00:00" } ], "nextCursor": "eyJoaSI6IjIwMjYtMDktMDNUMDg6MDY6NTcuMjQ2MDAwKzAwOjAwIiwibG8iOiIyMDI2LTA4LTA0VDA4OjA2OjU3LjI0NjAwMCswMDowMCIsInIiOjMsInYiOjYxNDE5MjI0LjY5NTg5OTA4NCwidyI6IjB4MzAzODExNjg2NzVhOWM5N2RiZWIxMDhiOGNkZTMzYWJhNDM0YmU0ZiJ9" } ``` ⚠ `sort=pnl` ranks `sum(closed_pnl)` — the **traders'** realized result, not the builder's. It is signed, and a builder whose users lose money is a different business from one whose users win. # /builders/{builder} (/docs/endpoints/data/hyperliquid/builders-builder) ``` GET https://data.vike.io/v1/hyperliquid/builders/{builder} ``` One builder: window totals plus per-asset and per-dex breakdowns, and an unwindowed `lifetime` block that is what makes a 30-day window interpretable. Truncated: `by_asset` and `by_dex` each returned 3 rows (`breakdown_limit=3`), and `user_volume_distribution` returned all 10 deciles; one row of each breakdown and the first 2 deciles are shown. `totals` and `lifetime` are complete. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/builders/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b?window=30d&breakdown_limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Builder address `1d` \| `7d` \| `30d` \| `all` . Default `30d` Rows per breakdown. Default 100, max 1000 ```json title="Example response" { "builder": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", "window": { "window": "30d", "start": "2026-08-04T08:06:57.246000+00:00", "end": "2026-09-03T08:06:57.246000+00:00" }, "totals": { "fee_usd": 1292173.7512559998, "volume_usd": 2459698781.897407, "share_volume_usd": 2459698781.897407, "fills": 1276030, "users": 28785, "revenue_per_user": 44.8905246224075, "fee_rate_bps": 5.253382083879472, "fee_usd_node": 1292173.7512559998, "trader_fee_usd": 2179038.6845209985, "builder_routed_deployer_fee_usd": 32029.181042999997, "active_days": 31, "crossed_fills": 1264333, "taker_pct": 99.08332876186296, "first_seen": "2026-08-04T08:06:58.003000+00:00", "last_seen": "2026-09-03T08:06:53.487000+00:00", "fee_rate_bps_p50": 4.99999153161867, "trader_fee_rate_bps": 8.858965579680014, "routed_closed_pnl_usd": 5411823.867884997, "assets": 281, "dexes": 3, "buy_volume_usd": 1235042429.759386, "sell_volume_usd": 1224656352.1380203, "non_usd_fee_fills": 0, "non_usd_fee_volume_usd": 0.0, "profitable_users": 14950, "profitable_users_pct": 51.93677262463089, "market_share_pct": 0.4897971159612295, "venue_volume_usd": 502187273412.22394, "market_share_window_start": "2026-08-04T08:06:57.246000+00:00", "biggest_day": { "date": "2026-08-21", "fee_usd": 109113.4776180001, "volume_usd": 218226896.66830376, "fills": 94276, "users": 6570 } }, "by_asset": [ { "asset": "BTC", "fee_usd": 509022.96058600006, "volume_usd": 1018046131.9743183, "share_volume_usd": 1018046131.9743183, "fills": 242890, "users": 14148, "revenue_per_user": 35.97843939680521, "fee_rate_bps": 4.9999989646720735, "fee_usd_node": 509022.96058600006, "trader_fee_usd": 926359.8724370012, "builder_routed_deployer_fee_usd": 0.0, "closed_pnl_usd": 853354.1283130016, "active_days": 31, "crossed_fills": 241199, "taker_pct": 99.30380007410761, "first_seen": "2026-08-04T08:07:07.023000+00:00", "last_seen": "2026-09-03T08:06:34.662000+00:00", "fee_rate_bps_p50": 4.999993656788234 } ], "by_dex": [ { "dex_name": "HL", "fee_usd": 988497.2047849979, "volume_usd": 1976995273.7509472, "share_volume_usd": 1976995273.7509472, "fills": 983697, "users": 25961, "revenue_per_user": 38.076237617387534, "fee_rate_bps": 4.99999781440815, "fee_usd_node": 988497.2047849979, "trader_fee_usd": 1804190.5380139945, "builder_routed_deployer_fee_usd": 0.0, "closed_pnl_usd": 3855774.905621013, "active_days": 31, "crossed_fills": 976935, "taker_pct": 99.31259320705462, "first_seen": "2026-08-04T08:07:07.023000+00:00", "last_seen": "2026-09-03T08:06:48.794000+00:00", "fee_rate_bps_p50": 4.999988076025987 } ], "user_volume_distribution": [ { "decile": 1, "users": 2879, "volume_usd": 86655.32764499992, "volume_share_pct": 0.0035230056738148317, "fee_usd": 45.37262100000003, "min_volume_usd": 0.003927, "max_volume_usd": 54.04912, "profitable_users": 875, "profitable_users_pct": 30.392497394928796, "closed_pnl_usd": 271.7052880000002 }, { "decile": 2, "users": 2879, "volume_usd": 275014.79240599985, "volume_share_pct": 0.01118083215839356, "fee_usd": 143.7351350000003, "min_volume_usd": 54.053650000000005, "max_volume_usd": 148.43142, "profitable_users": 1157, "profitable_users_pct": 40.187565126780136, "closed_pnl_usd": 1358.2858930000023 } ], "lifetime": { "first_seen": "2025-06-12T01:30:06+00:00", "last_seen": "2026-09-03T08:06:53.487000+00:00", "active_days": 449, "fills": 20546541, "users": 169550, "fee_usd": 25097457.752429612, "volume_usd": 49197763660.69259, "revenue_per_user": 148.0239324826282 } } ``` ⚠ `fee_rate_bps` is the **realized effective** rate (fee ÷ volume); the venue's configured per-order rate is not in the data. `fee_rate_bps_p50` — the median across fills that actually paid a builder fee — is the closest observable thing to an advertised rate, because 21% of rows pay the builder nothing and drag the mean. `user_volume_distribution` splits users into volume deciles, so "19k users" and "one whale plus a tail" stop looking the same. `profitable_users` is a per-**wallet** test (`sum(closed_pnl) > 0`), not a per-fill one: a wallet that won once and lost once is one loser. # /builders/stats (/docs/endpoints/data/hyperliquid/builders-stats) ``` GET https://data.vike.io/v1/hyperliquid/builders/stats ``` Builder-code economics for the whole venue — how many builders are active, how many distinct traders route through one, what share of all Hyperliquid fill notional carries a builder code, and how that moved day by day. `lifetime.fee_usd` is all-time and unwindowed. Truncated: `daily` holds **31** buckets; the first 2 are shown. `totals` and `lifetime` are complete. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/builders/stats?window=30d" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `1d` \| `7d` \| `30d` \| `all` . Default `30d` ```json title="Example response" { "window": { "window": "30d", "start": "2026-08-04T08:06:47.379000+00:00", "end": "2026-09-03T08:06:47.379000+00:00" }, "totals": { "fee_usd": 8905954.222539285, "volume_usd": 21384665654.34212, "share_volume_usd": 21384665654.34212, "fills": 21464158, "users": 175990, "revenue_per_user": 50.60488790578604, "fee_rate_bps": 4.1646450622579385, "fee_usd_node": 8905954.222539285, "trader_fee_usd": 15342433.846149018, "builder_routed_deployer_fee_usd": 276863.60111752996, "active_days": 31, "crossed_fills": 15039788, "taker_pct": 70.06931275850653, "first_seen": "2026-08-04T08:06:47.689000+00:00", "last_seen": "2026-09-03T08:06:47.379000+00:00", "builders": 773, "assets": 1189, "routed_closed_pnl_usd": 40440208.459194675, "venue_volume_usd": 502186586606.7108, "builder_routed_share_pct": 4.258310800142816, "market_share_window_start": "2026-08-04T08:06:47.379000+00:00" }, "lifetime": { "fee_usd": 102158419.77212027, "first_seen": "2024-10-27T18:15:13+00:00", "last_seen": "2026-09-03T08:06:47.379000+00:00" }, "daily": [ { "date": "2026-09-03", "builders": 313, "assets": 416, "fee_usd": 68310.92594833038, "volume_usd": 189551581.736281, "share_volume_usd": 189551581.736281, "fills": 314223, "users": 14027, "revenue_per_user": 4.869959788146459, "fee_rate_bps": 3.6038172471369765, "fee_usd_node": 68310.92594833038, "trader_fee_usd": 119872.31359066741, "builder_routed_deployer_fee_usd": 3257.024262140485, "crossed_fills": 193620, "taker_pct": 61.618659359754055, "routed_closed_pnl_usd": -173991.42875996322 }, { "date": "2026-09-02", "builders": 426, "assets": 519, "fee_usd": 243552.30523937644, "volume_usd": 649788619.894209, "share_volume_usd": 649788619.894209, "fills": 960364, "users": 31046, "revenue_per_user": 7.8448851781027, "fee_rate_bps": 3.748177450061049, "fee_usd_node": 243552.30523937644, "trader_fee_usd": 436195.37178474676, "builder_routed_deployer_fee_usd": 15056.07115401947, "crossed_fills": 606377, "taker_pct": 63.14033012482767, "routed_closed_pnl_usd": -550294.1408118871 } ] } ``` ⚠ **`stats` is a reserved segment on this path.** `/builders/stats` is always this route, never a [`/builders/{builder}`](/docs/endpoints/data/hyperliquid/builders-builder) lookup — a builder address is a hex string, so nothing is shadowed, but do not expect `{builder}` to accept the literal `stats`. No coverage start date is quoted here, and none should be: builder history is imported from Hyperliquid's published per-builder fee CSVs and both the totals and the start move as that import proceeds. Read `lifetime.first_seen` for the actual coverage start — a coverage date quoted in prose here was measured 40% low and eight months stale within a day of being written. # /builders (/docs/endpoints/data/hyperliquid/builders) ``` GET https://data.vike.io/v1/hyperliquid/builders ``` The builder-code leaderboard: fee revenue, routed volume, unique traders, fills, `market_share_pct`. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/builders?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `1d` \| `7d` \| `30d` \| `all` . Default `30d` `fee` \| `volume` \| `users` \| `fills` . Default `fee` `asc` \| `desc` . Default `desc` Default 50, max 500 Keyset cursor ```json title="Example response" { "window": { "window": "30d", "start": "2026-08-04T08:06:47.379000+00:00", "end": "2026-09-03T08:06:47.379000+00:00" }, "sort": "fee", "order": "desc", "venue_volume_usd": 502186586606.7108, "market_share_window_start": "2026-08-04T08:06:47.379000+00:00", "builders": [ { "builder": "0xe95a5e31904e005066614247d309e00d8ad753aa", "rank": 1, "fee_usd": 1499518.2846610104, "volume_usd": 1618501175.9291677, "share_volume_usd": 1618501175.9291677, "fills": 529981, "users": 9437, "revenue_per_user": 158.89777309113177, "fee_rate_bps": 9.264857554398438, "fee_usd_node": 1499518.2846610104, "trader_fee_usd": 1911054.1162650166, "builder_routed_deployer_fee_usd": 21187.156330000034, "closed_pnl_usd": 4617426.625648984, "active_days": 31, "crossed_fills": 495218, "taker_pct": 93.44070825180525, "first_seen": "2026-08-04T08:07:13.004000+00:00", "last_seen": "2026-09-03T08:06:41.614000+00:00", "market_share_pct": 0.32229080168497265, "first_seen_lifetime": "2025-08-19T22:14:56+00:00" }, { "builder": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", "rank": 2, "fee_usd": 1292163.5658959982, "volume_usd": 2459678115.583188, "share_volume_usd": 2459678115.583188, "fills": 1276026, "users": 28783, "revenue_per_user": 44.8932899939547, "fee_rate_bps": 5.253384813685782, "fee_usd_node": 1292163.5658959982, "trader_fee_usd": 2179019.0573659376, "builder_routed_deployer_fee_usd": 32029.19622199984, "closed_pnl_usd": 5411672.672201046, "active_days": 31, "crossed_fills": 1264329, "taker_pct": 99.08332588834396, "first_seen": "2026-08-04T08:06:50.331000+00:00", "last_seen": "2026-09-03T08:06:34.662000+00:00", "market_share_pct": 0.4897936705564566, "first_seen_lifetime": "2025-06-12T01:30:06+00:00" }, { "builder": "0x5af1b5f44207784dcb850bbb4143c5dcd1885f71", "rank": 3, "fee_usd": 1061419.7763880126, "volume_usd": 1460325459.6318128, "share_volume_usd": 1460325459.6318128, "fills": 440461, "users": 9616, "revenue_per_user": 110.38059238644057, "fee_rate_bps": 7.268378219302051, "fee_usd_node": 1061419.7763880126, "trader_fee_usd": 1565598.9851990165, "builder_routed_deployer_fee_usd": 29707.97636299997, "closed_pnl_usd": 4744467.146989997, "active_days": 31, "crossed_fills": 429636, "taker_pct": 97.5423476766388, "first_seen": "2026-08-04T08:06:49.661000+00:00", "last_seen": "2026-09-03T08:06:47.072000+00:00", "market_share_pct": 0.29079340201005244, "first_seen_lifetime": "2026-04-09T17:31:47.951000+00:00" } ], "nextCursor": "eyJiIjoiMHg1YWYxYjVmNDQyMDc3ODRkY2I4NTBiYmI0MTQzYzVkY2QxODg1ZjcxIiwiaGkiOiIyMDI2LTA5LTAzVDA4OjA2OjQ3LjM3OTAwMCswMDowMCIsImxvIjoiMjAyNi0wOC0wNFQwODowNjo0Ny4zNzkwMDArMDA6MDAiLCJyIjozLCJ2IjoxMDYxNDE5Ljc3NjM4ODAxMjZ9" } ``` `sort=fee` ranks the builder's own cut, not the trader's total cost, and the two orderings differ materially — take rates range from roughly 0.2 bps to 3.4 bps across the top of the board. ⚠ **`market_share_pct` is measured over an overlap window that can start later than yours.** Its denominator is all Hyperliquid fill notional, whose history is shorter than the builder-fill history — the latter reaches months further back via Hyperliquid's published CSVs. That gap is permanent, not a backlog, so the share is taken from `market_share_window_start` to `window.end`. To audit it, divide the per-row `share_volume_usd` (the clamped numerator) by the top-level `venue_volume_usd`. **Do not use `volume_usd`** — that is the row's full unclamped notional for the requested window, so on `window=all` it can exceed the numerator by more than 2x. ⚠ `active_days` is on every row because the historical backfill of builder fills is still running: a long window can currently cover fewer days than it names, and `active_days` beside the resolved `window.start`/`window.end` is how you tell a quiet builder from an unfilled window. `first_seen_lifetime` is **not** window-scoped (`first_seen` is) — it is the builder's genuine first fill. Every `*_pct` field here is percentage points, 0–100. The three fee columns are not interchangeable — see [Builders](/docs/endpoints/data/hyperliquid#builders). # /cohorts (/docs/endpoints/data/hyperliquid/cohorts) ``` GET https://data.vike.io/v1/hyperliquid/cohorts ``` The canonical cohort ladders as flat label sets — the exact strings `?cohort=` accepts. Takes no parameters. The shape is `{"size": [...12], "pnl": [...12, "unknown"], "tier": [...10]}`, and it is served from the same label set `?cohort=` is validated against — so a label is accepted on its axis if and only if it appears here. `unknown` rides the `pnl` set because it is a real, queryable bucket there (wallets under 10 closed trades), not a rung of the ladder. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/cohorts" \ -H "X-API-KEY: $VIKE_API_KEY" ``` ```json title="Example response" { "size": [ "4x whale", "3x whale", "2x whale", "whale", "2x shark", "shark", "2x dolphin", "dolphin", "2x fish", "fish", "2x shrimp", "shrimp" ], "pnl": [ "3x smart", "2x smart", "smart", "3x winner", "2x winner", "winner", "loser", "2x loser", "3x loser", "rekt", "2x rekt", "3x rekt", "unknown" ], "tier": [ ">$2.5m", "$1m-$2.5m", "$500k-$1m", "$250k-$500k", "$100k-$250k", "$50k-$100k", "$25k-$50k", "$10k-$25k", "$1k-$10k", "<$1k" ] } ``` ⚠ **`pnl_abs` is a valid `axis=` and is *not* in this response.** `/cohorts` publishes three ladders; the dollar-banded `pnl_abs` ladder is published by [`/segments`](/docs/endpoints/data/hyperliquid/segments) only. A client that enumerates axes from this endpoint will miss it. # /coins/{coin}/cohort-metrics (/docs/endpoints/data/hyperliquid/coins-coin-cohort-metrics) ``` GET https://data.vike.io/v1/hyperliquid/coins/{coin}/cohort-metrics ``` The full cohort matrix for one coin — every bucket on an axis, hourly. [`/coins/{coin}/metrics`](/docs/endpoints/data/hyperliquid/coins-coin-metrics) returns one row per hour for a single cohort or for all of them summed; rebuilding the matrix from it costs one request per cohort (12 for `size`, 13 for `pnl`). This returns all of them in one. Truncated: `metrics` holds **72** rows — 6 hourly buckets x 12 `size` cohorts, which is the matrix this endpoint exists to return in one call; the first 2 are shown. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/coins/BTC/cohort-metrics?axis=size&hours=6" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker `size` \| `pnl` \| `pnl_abs` \| `tier` \| `pnl_unrealized` . Default `size` ISO-8601 UTC bounds `24h` \| `7d` \| `30d` \| `all` . Default `all` Number of hourly buckets. Default 168, max 8760 Cursor over **hours** `realized` \| `realized-pit` \| `unrealized` . Refused on `size` , `tier` , `pnl_abs` `current` \| `point_in_time` . Default depends on the axis ```json title="Example response" { "axis": "size", "label_basis": "point_in_time", "metrics": [ { "ts": "2026-09-03T07:00:00+00:00", "cohort": "4x whale", "position_count": 20, "position_count_long": 6, "total_position_value": 704156131.5, "total_position_value_long": 182556075.6, "total_unrealized_pnl": -24295055.1, "total_position_size": 9061.33228, "total_position_size_long": 2349.1967, "bias": -0.4814897793, "label_basis": "point_in_time", "grading": null }, { "ts": "2026-09-03T07:00:00+00:00", "cohort": "3x whale", "position_count": 26, "position_count_long": 14, "total_position_value": 265561588.5, "total_position_value_long": 171376394.8, "total_unrealized_pnl": 20255408.17, "total_position_size": 3417.34125, "total_position_size_long": 2205.33258, "bias": 0.2906715594, "label_basis": "point_in_time", "grading": null } ], "hours": 6, "nextCursor": "eyJtIjoiY29ob3J0X21ldHJpY3MiLCJ0IjoiMjAyNi0wOS0wMyAwMjowMDowMCJ9", "grading": null, "freshness": { "newest_ts": "2026-09-03T07:00:00+00:00", "behind_hours": 1, "settled_lag_ceiling_hours": 3, "stale": false } } ``` **Paginated by hour, not by row.** A row limit would cut a bucket in half and return an hour whose cohorts do not sum to its total. `hours` bounds the buckets; every cohort in a returned bucket comes with it. **No `provisional` row here.** This is bulk history, and a partial hour inside a matrix that will be differenced or z-scored produces a fake move at the right edge. Use [`/coins/{coin}/metrics`](/docs/endpoints/data/hyperliquid/coins-coin-metrics) for the current value. `axis=tier` is the per-position ladder — each row's own notional bucket, derived from its `size_usd`, largest first: `>$2.5m`, `$1m-$2.5m`, `$500k-$1m`, `$250k-$500k`, `$100k-$250k`, `$50k-$100k`, `$25k-$50k`, `$10k-$25k`, `$1k-$10k`, `<$1k`. It is a grouping **only here**, because this endpoint aggregates position rows and each row sits in exactly one bucket; the per-wallet endpoints keep rejecting it. By default the `pnl` cohort is resolved from the dictionary **at query time**, so a historical row carries today's grading. That is correct for "who is smart right now" and **wrong for a backtest**, because a wallet under the 10-closed-trade floor back then may be graded now. Pass `labelBasis=point_in_time` for each row's label as it was that hour. The point-in-time response then **omits five fields**, listed in `unavailable_fields`: `position_count`, `position_count_long`, `total_unrealized_pnl`, `total_position_size` and `total_position_size_long` — the frozen source does not carry them. `label_computed.lookahead` on the envelope states which of the two you got. Do not infer it. **What each `grading` selects:** * `grading=realized` (the default on `axis=pnl`) — the percentile ladder over cumulative realized PnL. With the default `labelBasis` this is resolved at query time and `label_computed.lookahead` is **true**. With `labelBasis=point_in_time` it is the frozen ladder, and `label_computed` names a **2026-08-06 seam**: before it the labels come from the 2026-08-17 point-in-time rebuild (daily resolution), from it they come from the row's own hourly column. Both halves are honest, but a series crossing the seam changes label *resolution* — a turnover count will read that as a regime change that is an artefact. * `grading=realized-pit` — the same ladder graded point-in-time across the **whole** window (day D on the cumulative as of the end of D−1), which is what makes it the control for a study crossing that seam. * `grading=unrealized` — "currently onside": hour H's rank over the wallet's last snapshot strictly before H. `axis=pnl_unrealized` is the legacy spelling of exactly this and keeps working; both reach one branch. `realized-pit` and `unrealized` refuse `labelBasis=current` rather than silently overriding it. On any window that reaches the present the envelope carries `{newest_ts, behind_hours, settled_lag_ceiling_hours, stale}`. `stale` is the pre-computed verdict, so a consumer does not have to re-derive the rollup's cadence. It exists because prose was not enough. `grading=realized-pit` reads a **frozen research artefact**: nothing has written to it since 2026-08-16 19:00 UTC. Ask it for an explicit recent window and it honestly answers with zero rows — but ask it the way a poller does (`?grading=realized-pit&hours=1`, no `start`) and it returns its own newest bucket, **identical in shape to a current one**, and days old. That gap is not a constant: it grows by 24 hours a day. For live trading use `grading=realized` with `labelBasis=point_in_time` — honest labels, and one hour behind the clock rather than days. A window ending in the **past** gets no `freshness` block at all: "the newest bar is four months old" answers a question a historical query did not ask, and would make every backtest fetch look broken. ⚠ **`axis=pnl_unrealized` grades positioning, not skill.** Each wallet is ranked by the unrealized PnL of its open positions as a share of account value, over its last snapshot strictly before hour H. That grading turns over about **44% of wallets per day** against the realized ladder's **2.4%** — which is the point: it is a different axis, not a re-spelling. Twelve rungs, no `unknown` (eligibility is a positive-equity snapshot, not a trade count), the same five omitted fields as the frozen `pnl` source, coverage from 2026-04-04 with a known three-day hole (2026-06-28 to 06-30, graded off days whose equity feed was down). It is **hourly**, and has been since the rollup moved on 2026-08-19; anything describing it as a daily grading is stale. # /coins/{coin}/liquidation-heatmap (/docs/endpoints/data/hyperliquid/coins-coin-liquidation-heatmap) ``` GET https://data.vike.io/v1/hyperliquid/coins/{coin}/liquidation-heatmap ``` Where liquidations *would* cluster if price moved: every open position's own liquidation price, binned and weighted by notional. Truncated: `bins` holds **10** rows (`bins=10`); the first 3 are shown. `window` and `coverage` are complete. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/coins/BTC/liquidation-heatmap?bins=10" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker Number of price bins. Default 50, min 5, max 500 ```json title="Example response" { "coin": "BTC", "mark_price": 78011.0, "snapshot_ts": "2026-09-03T07:44:00+00:00", "bins": [ { "price_bin_index": 0, "price_bin_start": 39005.5, "price_bin_end": 46806.6, "liquidation_value_usd": 136593494.62892, "positions_count": 1669, "long_value_usd": 136338635.03225002, "short_value_usd": 254859.59667000003, "most_impacted_cohort": "whale" }, { "price_bin_index": 1, "price_bin_start": 46806.6, "price_bin_end": 54607.7, "liquidation_value_usd": 235951565.08872, "positions_count": 2492, "long_value_usd": 235829551.98417, "short_value_usd": 122013.10455, "most_impacted_cohort": "4x whale" }, { "price_bin_index": 2, "price_bin_start": 54607.7, "price_bin_end": 62408.8, "liquidation_value_usd": 118453087.38709, "positions_count": 2373, "long_value_usd": 118391459.4772, "short_value_usd": 61627.909889999995, "most_impacted_cohort": "whale" } ], "total_value_usd": 1975949798.55437, "cohort_axis": "size", "window": { "price_low": 39005.5, "price_high": 117016.5, "range_pct": 0.5, "bin_width": 7801.1, "spacing": "linear" }, "coverage": { "open_value_usd": 3034690460.1413383, "open_positions": 36192, "no_liq_price_value_usd": 104748153.91258995, "no_liq_price_positions": 5383, "outside_window_value_usd": 953992507.6743784, "in_window_fraction_of_open": 0.6511207072046292, "in_window_fraction_of_priced": 0.6743988761668478 } } ``` ⚠ This is **hypothetical**, not realized — it is the standing risk on the book right now, not a record of anything that happened. [`/liquidations`](/docs/endpoints/data/hyperliquid/liquidations) is the realized surface. (Its path lives under `/coins/`, but it answers a liquidations question rather than a positioning one, which is why it is filed here rather than beside the other `/coins/` routes.) # /coins/{coin}/metrics (/docs/endpoints/data/hyperliquid/coins-coin-metrics) ``` GET https://data.vike.io/v1/hyperliquid/coins/{coin}/metrics ``` Hourly open-interest / position-count / bias series for one coin, optionally sliced to a single cohort. This is the **current-value** endpoint; [`/cohort-metrics`](/docs/endpoints/data/hyperliquid/coins-coin-cohort-metrics) is the bulk-history one. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/coins/BTC/metrics?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker ISO-8601 UTC bounds `size` \| `pnl` \| `pnl_abs` . **Omit for all cohorts combined** One label on that axis `24h` \| `7d` \| `30d` \| `all` . Default `all` Default 500, max 5000 Keyset cursor `current` only — see the refusal below ```json title="Example response" { "axis": "size", "label_basis": "point_in_time", "metrics": [ { "ts": "2026-09-03T07:59:00+00:00", "position_count": 36124, "position_count_long": 22181, "total_position_value": 3009666609.0, "total_position_value_long": 1504833304.0, "total_unrealized_pnl": -25275224.09, "total_position_size": 38729.4635, "total_position_size_long": 19364.73175, "bias": -2.376528135E-16, "provisional": true, "label_basis": "point_in_time" }, { "ts": "2026-09-03T07:00:00+00:00", "position_count": 37385, "position_count_long": 22704, "total_position_value": 3040705315.0, "total_position_value_long": 1524148585.0, "total_unrealized_pnl": -25163932.39, "total_position_size": 39127.71343, "total_position_size_long": 19612.467, "bias": 0.002496741284, "provisional": false, "label_basis": "point_in_time" }, { "ts": "2026-09-03T06:00:00+00:00", "position_count": 36982, "position_count_long": 22508, "total_position_value": 3038908973.0, "total_position_value_long": 1518681456.0, "total_unrealized_pnl": -25099074.21, "total_position_size": 39060.76211, "total_position_size_long": 19520.44664, "bias": -0.0005087549732, "provisional": false, "label_basis": "point_in_time" }, { "ts": "2026-09-03T05:00:00+00:00", "position_count": 37127, "position_count_long": 22594, "total_position_value": 3009207480.0, "total_position_value_long": 1496950278.0, "total_unrealized_pnl": -25051797.85, "total_position_size": 38755.01536, "total_position_size_long": 19278.62806, "bias": -0.005086696384, "provisional": false, "label_basis": "point_in_time" } ], "nextCursor": "eyJtIjoibWV0cmljcyIsInQiOiIyMDI2LTA5LTAzIDA1OjAwOjAwIn0" } ``` `openedWithin` is **rolling per bucket, not relative to now**: each hour counts positions opened in the window before *that* hour. Hourly by construction. Summing the 5-minute snapshot table into hour buckets over-counts open interest roughly 12x, which is what forced the previous cohort aggregate to be rebuilt. ⚠ **Every row carries `provisional`, and it is `true` on at most one of them** — the newest, covering the hour still in progress. The hourly rollup lands 60–120 minutes late, so without that row a "current positioning" read would be silently that stale. Its `ts` is a snapshot instant rather than an hour boundary and it changes on every poll: **drop it when persisting a series.** It is present only on the first page of a window reaching the present. Its cohort label is resolved from the wallet dictionary at **query time**, so every historical hour it returns carries today's grading. Measured 2026-04-20T12:00Z on BTC's `3x smart` bucket: 938,495,920.6 of notional against 861,706,562.3 under the point-in-time labelling of the same hour — **8.91% inflated**. The 400 names [`/coins/{coin}/cohort-metrics`](/docs/endpoints/data/hyperliquid/coins-coin-cohort-metrics) with `axis=pnl&labelBasis=point_in_time`, which reads the frozen ladder. It refuses rather than serving it because the frozen column is 0% filled before 2026-08-06 and this endpoint's whole value is the long window: answering recent hours only would silently truncate some windows and not others. ⚠ **Before 2026-08-19 this route accepted no `labelBasis` at all**, and an unrecognised query parameter was ignored rather than refused — so `?labelBasis=point_in_time` returned **HTTP 200 with lookahead rows**. A caller who explicitly asked for the honest labelling was told yes and given the contaminated answer. If you have stored results from that period, they carry today's grading regardless of what you asked for. The refusal exists so that can no longer happen silently. # /coins/{coin}/size-breakdown (/docs/endpoints/data/hyperliquid/coins-coin-size-breakdown) ``` GET https://data.vike.io/v1/hyperliquid/coins/{coin}/size-breakdown ``` Positioning for one coin split by the canonical ten position-size buckets — the same ladder, spellings and bounds as [`axis=tier` on `/coins/{coin}/cohort-metrics`](/docs/endpoints/data/hyperliquid/coins-coin-cohort-metrics). Empty buckets come back zero-filled so the table shape is stable. Truncated: the array holds all **10** tiers, largest first; the first 3 are shown. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/coins/BTC/size-breakdown" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker ```json title="Example response" [ { "tier": ">$2.5m", "min_size": 2500000.0, "max_size": null, "position_count": 177, "total_position_value": 2115975888.0, "total_position_value_long": 1033347966.0, "value_close_to_liquidation": 41070622.45, "bias": -0.02328946914 }, { "tier": "$1m-$2.5m", "min_size": 1000000.0, "max_size": 2500000.0, "position_count": 183, "total_position_value": 292004085.3, "total_position_value_long": 146257572.2, "value_close_to_liquidation": 17869289.39, "bias": 0.001750177871 }, { "tier": "$500k-$1m", "min_size": 500000.0, "max_size": 1000000.0, "position_count": 237, "total_position_value": 162494247.6, "total_position_value_long": 91367908.78, "value_close_to_liquidation": 10808042.57, "bias": 0.1245679168 } ] ``` No query parameters. # /heatmap (/docs/endpoints/data/hyperliquid/heatmap) ``` GET https://data.vike.io/v1/hyperliquid/heatmap ``` The coin × cohort positioning matrix — the whole board in one call. Truncated: `heatmap` holds **313** coins and the full response is **879,265 bytes** — worth knowing before you call the whole board in one request. One coin with the first 2 of its 12 segments is shown; `totals` is complete. A coin carries only the cohorts that actually hold it, so the per-coin segment count ranges from 2 to 12 across this response. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/heatmap?axis=size" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `size` \| `pnl` \| `pnl_abs` . Default `size` . `trade_size` is rejected `24h` \| `7d` \| `30d` \| `all` . Default `all` ```json title="Example response" { "axis": "size", "opened_within": "all", "snapshot_ts": "2026-09-03T08:04:00Z", "source": "latest position snapshot", "totals": { "total_value": 13836781540.0, "total_long_value": 6925016569.0, "total_short_value": 6911764971.0, "bias": 0.0009577081042, "count": 359587, "n_coins": 313, "n_wallets": 123747, "n_wallets_source": "precomputed totals" }, "heatmap": [ { "coin": "BTC", "total_value": 3026943358.0, "total_long_value": 1518666445.0, "total_short_value": 1508276913.0, "count": 36436, "count_long": 22334, "count_short": 14102, "n_wallets": 36348, "bias": 0.003432351074, "segments": [ { "cohort": "4x whale", "rank": 1, "total_value": 704160990.8, "total_long_value": 182556075.6, "total_short_value": 521604915.2, "count": 21, "count_long": 6, "count_short": 15, "n_wallets": 20, "bias": -0.4814933575, "share_of_coin": 0.23263104313286428 }, { "cohort": "3x whale", "rank": 2, "total_value": 295869139.7, "total_long_value": 203749222.9, "total_short_value": 92119916.83, "count": 26, "count_long": 15, "count_short": 11, "n_wallets": 26, "bias": 0.3772928335, "share_of_coin": 0.0977451854207464 } ] } ] } ``` Per-wallet ladders only — this groups wallets, and a wallet straddles position-size buckets. Check the `groupable` field on [`/segments`](/docs/endpoints/data/hyperliquid/segments). # /hype/holders (/docs/endpoints/data/hyperliquid/hype-holders) ``` GET https://data.vike.io/v1/hyperliquid/hype/holders ``` The HYPE holder leaderboard — spot balance plus staked, per wallet. A wallet appearing in only one of the two sources is still a holder, so the two are unioned rather than joined. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/hype/holders?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `asc` \| `desc` . Default `desc` Default 100, max 500 Keyset cursor ```json title="Example response" { "holders": [ { "wallet": "0x43e9abea1910387c4292bca4b94de81462f8a251", "rank": 1, "spot_amount": 3.00327328, "spot_amount_usd": 246.15128130208, "staked_amount": 241107400.82591507, "staked_amount_usd": 19761403679.092823, "total_amount": 241107403.82918835, "total_amount_usd": 19761403925.244106, "account_value": 0.0, "size_cohort": null, "pnl_cohort": "unknown" }, { "wallet": "0xd57ecca444a9acb7208d286be439de12dd09de5d", "rank": 2, "spot_amount": 4.4E-7, "spot_amount_usd": 0.00003606284, "staked_amount": 60365562.87615384, "staked_amount_usd": 4947621898.892445, "total_amount": 60365562.87615428, "total_amount_usd": 4947621898.892481, "account_value": 0.0, "size_cohort": null, "pnl_cohort": "unknown" }, { "wallet": "0x2222222222222222222222222222222222222222", "rank": 3, "spot_amount": 54535484.50059421, "spot_amount_usd": 4469782845.153202, "staked_amount": 0.0, "staked_amount_usd": 0.0, "total_amount": 54535484.50059421, "total_amount_usd": 4469782845.153202, "account_value": 0.0, "size_cohort": null, "pnl_cohort": "unknown" } ], "hype_price_usd": 81.961, "label_basis": "current", "label_computed": { "grading": "realized", "label_basis": "current", "when": "at query time", "lookahead": false, "source": "live wallet realized-PnL ladder", "detail": "Balances are last-known FINAL state and the label is resolved now: row and label are both 'now'. There is no time axis here, so a current label cannot contaminate an earlier row — the lookahead hazard the liquidation listing declares does not exist on this shape.", "caveat": "Balances are last-known, not simultaneous: each wallet carries whatever its most recent poll saw, so a stale balance can sit beside a fresh label." }, "nextCursor": "eyJyYW5rIjozLCJ0b3RhbCI6NTQ1MzU0ODQuNTAwNTk0MjEsInciOiIweDIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIifQ" } ``` `rank` is position in the **requested** ordering: 1 is the largest holder under `order=desc` and the smallest under `order=asc`. ⚠ **Balances are last-known and the USD column is not.** The USD figure applies a single current HYPE price to every row rather than each row's own recorded `mark_price` — a wallet last polled in June would otherwise carry a June price and the column would be non-comparable row to row. The balance itself is still whatever was last seen, so a stale balance can sit beside a fresh price. Staked balance is `amount_delegated` only; `pending_rewards` is excluded because it is 0 across the whole table today, and folding it in would silently change the definition the day it stops being 0. `pnl_cohort` here is resolved at query time and the envelope says so (`label_basis: "current"` plus `label_computed`). That is correct rather than a fallback: the leaderboard has no time axis, so there is no earlier row for a current label to contaminate. # Hyperliquid REST (/docs/endpoints/data/hyperliquid) Thirty-five operations, every one a `GET`, all under `https://data.vike.io/v1/hyperliquid/`. This is the largest namespace in the API by some distance, and the only one with per-wallet history: who holds what, at what entry, with what realized PnL. Every path in this namespace is written **relative to `https://data.vike.io/v1/hyperliquid`**. The fenced line at the top of each endpoint's page carries the full path. ## Before you call anything [#before-you-call-anything] **Auth.** `X-API-Key: vk_...` or `Authorization: Bearer vk_oat_...`; either alone is enough. See [Authentication](/docs/data/authentication). Every call spends one unit of the shared hourly counter — see [Rate limits](/docs/data/rate-limits). **Errors.** Every route in this namespace maps failures the same way: input the service rejected is a **400**, an upstream data failure is a **503**, and a rate-limit trip is a **429**. A failure 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. See [Errors](/docs/data/errors). **Pagination is keyset, not offset.** The position-snapshot history is 7.3 billion rows and the fill history 1.07 billion; an offset re-reads and discards every skipped row, so deep pages cost linearly more and a concurrent insert shifts rows across page boundaries. Every list route returns `nextCursor` — **pass it back verbatim**, do not construct one. **Aggregates are snapped to a canonical precision before they leave.** A large floating-point sum is not reproduced bit-for-bit run to run, so without that step two identical requests could come back differing in their last digits. Snapping them is what makes a response safe to cache, diff or content-address. Two routes in this namespace advertise a bound that the service then clamps harder: [`/traders`](/docs/endpoints/data/hyperliquid/traders) documents `size` up to 100 and caps it at 20, and [`/positions/match`](/docs/endpoints/data/hyperliquid/positions-match) documents `limit` up to 500 and caps it at 50. Both are noted on their own pages. A request above the effective cap is not rejected — it is silently served at the cap. ## The cohort vocabulary [#the-cohort-vocabulary] Four query parameters recur across this namespace and mean specific things. Getting them wrong is the most expensive mistake available here, because most of the wrong answers are **200s**. ### `axis` — which ladder buckets a wallet [#axis--which-ladder-buckets-a-wallet] The general routes accept `size`, `pnl` and `pnl_abs`; [`/coins/{coin}/cohort-metrics`](/docs/endpoints/data/hyperliquid/coins-coin-cohort-metrics) accepts two more. | Axis | What it buckets | Where accepted | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | | `size` | Absolute-$ account-value tier. A stored column, so always point-in-time. | Everywhere an `axis=` exists | | `pnl` | **Percentile rank** of a wallet's cumulative realized PnL. A dictionary lookup, not a column; `unknown` below 10 closed trades. | Everywhere | | `pnl_abs` | The *same* cumulative realized PnL banded in **dollars** (`$1m+` … `<-$1m`). Always resolved at query time — no frozen source exists. | Everywhere | | `tier` | The per-**position** notional bucket, derived from the row's `size_usd`. | `/coins/{coin}/cohort-metrics` only | | `pnl_unrealized` | Rank by unrealized PnL of open positions as a share of account value — "currently onside". | `/coins/{coin}/cohort-metrics` only | | `trade_size` | Published by `/segments` as a real ladder, but **rejected as `axis=` everywhere** — a wallet straddles buckets. Filter on the `groupable` field. | Nowhere | They are a re-labelling of one quantity, not two measurements — which is why the catalogue reports a distinct `basis` string for each. One ladder's `$100k-$1m` and the other's `smart` are not the same population. A client porting from a competitor whose PnL cohorts are absolute-dollar bands must read [`/segments`](/docs/endpoints/data/hyperliquid/segments) rather than assume the boundaries: the bucket *names* are similar and the *definitions* are not. The unrealized-PnL ladder that used to back `axis=pnl` was dropped 2026-08-03. ### `cohort` — one bucket on that ladder [#cohort--one-bucket-on-that-ladder] [`/cohorts`](/docs/endpoints/data/hyperliquid/cohorts) returns the exact strings `?cohort=` accepts, and it is generated from the same label set `?cohort=` is validated against, so a label listed here is a label the parameter accepts. Slugs from [`/segments`](/docs/endpoints/data/hyperliquid/segments) are accepted too. The retired `micro`/`small`/`medium`/`large`/`mega` vocabulary is gone: sending one gets a 400 naming its replacement rather than a generic refusal. ### `labelBasis` — *when* the label was computed [#labelbasis--when-the-label-was-computed] `current` (resolved from the wallet dictionary at query time) or `point_in_time` (the label as it stood in that row's own hour). Three axes are fixed and reject a conflicting value rather than silently overriding it: `size`, `tier` and `pnl_unrealized` are always `point_in_time`; `pnl_abs` is always `current`. ### `grading` — *which* PnL ladder does the labelling [#grading--which-pnl-ladder-does-the-labelling] `realized` | `realized-pit` | `unrealized`, on [`/coins/{coin}/cohort-metrics`](/docs/endpoints/data/hyperliquid/coins-coin-cohort-metrics) only, and refused on the axes that rank no PnL (`size`, `tier`, `pnl_abs`). A grading name is neutral by design — `realized` does not say whether the label is today's or the hour's. That is why every response carries a `label_computed` block on the envelope and on every row: the name is not allowed to travel alone. *** ## Wallets [#wallets] Seven routes. `{address}` is a Hyperliquid wallet address. | Path | What it answers | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | [`/wallets/{address}`](/docs/endpoints/data/hyperliquid/wallets-address) | One wallet in one call: equity, its cohort labels on each ladder, the risk-metric set, lifetime external flow and the open-positions block. | | [`/wallets/{address}/trades`](/docs/endpoints/data/hyperliquid/wallets-address-trades) | Closed round-trip trades for one wallet, newest first, cursor-paged. | | [`/wallets/{address}/trades/summary`](/docs/endpoints/data/hyperliquid/wallets-address-trades-summary) | Trade statistics for one wallet, overall **and per coin**. | | [`/wallets/{address}/fills`](/docs/endpoints/data/hyperliquid/wallets-address-fills) | Raw fills — the level below round-trip trades. | | [`/wallets/{address}/funding`](/docs/endpoints/data/hyperliquid/wallets-address-funding) | Funding paid and received by one wallet. | | [`/wallets/{address}/ledger`](/docs/endpoints/data/hyperliquid/wallets-address-ledger) | Deposits, withdrawals, transfers and vault events. | | [`/wallets/{address}/builders`](/docs/endpoints/data/hyperliquid/wallets-address-builders) | Reverse lookup: which builders this wallet's flow is routed through. | *** ## Builders [#builders] A builder is a front-end or bot that attaches its address to an order and is paid a slice of the taker fee for it. Six routes over builder-routed fills. `fee_usd` is the builder's **own revenue**. `trader_fee_usd` is the **total the trader paid**. `builder_routed_deployer_fee_usd` is the deployer's cut **on builder-routed flow only** — a lower bound on deployer revenue, never the whole of it. | Path | What it answers | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | [`/builders`](/docs/endpoints/data/hyperliquid/builders) | The builder-code leaderboard: fee revenue, routed volume, unique traders, fills, `market_share_pct`. | | [`/builders/stats`](/docs/endpoints/data/hyperliquid/builders-stats) | Builder-code economics for the whole venue. | | [`/builders/{builder}`](/docs/endpoints/data/hyperliquid/builders-builder) | One builder: window totals plus per-asset and per-dex breakdowns, and an unwindowed `lifetime` block. | | [`/builders/{builder}/users`](/docs/endpoints/data/hyperliquid/builders-builder-users) | The wallets routing through one builder, ranked. | | [`/builders/{builder}/fills`](/docs/endpoints/data/hyperliquid/builders-builder-fills) | Raw builder-routed fills, newest first. | | [`/builders/{builder}/series`](/docs/endpoints/data/hyperliquid/builders-builder-series) | Fee, volume, users and fills per bucket for one builder, newest first. | *** ## Cohorts and segments [#cohorts-and-segments] Three self-describing routes. Read these before hard-coding any bucket name. | Path | What it answers | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | [`/cohorts`](/docs/endpoints/data/hyperliquid/cohorts) | The canonical cohort ladders as flat label sets — the exact strings `?cohort=` accepts. | | [`/segments`](/docs/endpoints/data/hyperliquid/segments) | The detailed catalogue: every bucket on every axis with its boundaries, slug, rank, `basis` and a `groupable` flag. | | [`/segments/{cohort}/summary`](/docs/endpoints/data/hyperliquid/segments-cohort-summary) | Aggregate positioning for one cohort at the latest snapshot. | *** ## Coins [#coins] Hourly positioning series for one coin. `{coin}` is an HL coin ticker such as `BTC`. | Path | What it answers | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | [`/coins/{coin}/metrics`](/docs/endpoints/data/hyperliquid/coins-coin-metrics) | Hourly open-interest / position-count / bias series for one coin, optionally sliced to a single cohort. | | [`/coins/{coin}/cohort-metrics`](/docs/endpoints/data/hyperliquid/coins-coin-cohort-metrics) | The full cohort matrix for one coin — every bucket on an axis, hourly. | | [`/coins/{coin}/size-breakdown`](/docs/endpoints/data/hyperliquid/coins-coin-size-breakdown) | Positioning for one coin split by the canonical ten position-size buckets. | *** ## Positions [#positions] | Path | What it answers | | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | [`/positions`](/docs/endpoints/data/hyperliquid/positions) | Position rows, current or historical. | | [`/positions/coins`](/docs/endpoints/data/hyperliquid/positions-coins) | Market-wide open interest per coin at the latest snapshot, with the venue's own OI alongside our aggregate so the two can be reconciled. | | [`/positions/match`](/docs/endpoints/data/hyperliquid/positions-match) | "Who else is in this trade" — open positions matching a coin/side/leverage/entry profile. | *** ## Leaderboards [#leaderboards] | Path | What it answers | | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [`/traders`](/docs/endpoints/data/hyperliquid/traders) | Top HL perp traders by the requested metric over a window. | | [`/leaderboard`](/docs/endpoints/data/hyperliquid/leaderboard) | The trader leaderboard carrying the full risk-metric set — drawdown, Calmar, recovery factor, profit factor, expectancy and streaks beside PnL. | | [`/leaderboard/flow-adjusted`](/docs/endpoints/data/hyperliquid/leaderboard-flow-adjusted) | Ranked on **fills-derived realized PnL**, with external flow reported beside it. | *** ## Liquidations [#liquidations] Three routes, and they answer two different questions — one realized, two hypothetical. | Path | What it answers | | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | [`/liquidations`](/docs/endpoints/data/hyperliquid/liquidations) | Realized liquidation fills — what actually got liquidated. | | [`/coins/{coin}/liquidation-heatmap`](/docs/endpoints/data/hyperliquid/coins-coin-liquidation-heatmap) | Where liquidations *would* cluster if price moved: every open position's own liquidation price, binned and weighted by notional. | | [`/liquidations/risk`](/docs/endpoints/data/hyperliquid/liquidations-risk) | Share of each coin's open interest sitting close to liquidation, optionally for one cohort. | *** ## Ecosystem aggregates [#ecosystem-aggregates] | Path | What it answers | | ------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | [`/stats`](/docs/endpoints/data/hyperliquid/stats) | Ecosystem time series: perp equity, open interest, position counts. | | [`/stats/volume`](/docs/endpoints/data/hyperliquid/stats-volume) | Hourly perp volume — the whole venue, or one coin. | | [`/hype/holders`](/docs/endpoints/data/hyperliquid/hype-holders) | The HYPE holder leaderboard — spot balance plus staked, per wallet. | | [`/heatmap`](/docs/endpoints/data/hyperliquid/heatmap) | The coin × cohort positioning matrix — the whole board in one call. | | [`/assets/hourly`](/docs/endpoints/data/hyperliquid/assets-hourly) | Hourly close **plus** open interest, funding rate and premium, for many assets at once. | *** ## Candles [#candles] Two routes, and they are the whole crypto candle surface: Hyperliquid candles, built from the fills our own node sees. There is no second venue's candle route to reconcile the numbers against, so the parameter table and the response envelope on the endpoint's own page are the entire contract. | Path | What it answers | | ----------------------------------------------------------- | ------------------------------------------------- | | [`/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv) | Hyperliquid candles, derived from executed fills. | | [`/ohlcv/export`](/docs/endpoints/data/hyperliquid/ohlcv-export) | Streams the same single page as NDJSON. | *** ## Related [#related] * [Data MCP server](/docs/ai/data) — `hl_perp_top_traders` and `hl_position_match` run the same code as [`/traders`](/docs/endpoints/data/hyperliquid/traders) and [`/positions/match`](/docs/endpoints/data/hyperliquid/positions-match), agent-shaped. * [Rate limits](/docs/data/rate-limits) — the shared hourly counter these calls spend. * [Errors](/docs/data/errors) — the status codes and the error envelope. # /leaderboard/flow-adjusted (/docs/endpoints/data/hyperliquid/leaderboard-flow-adjusted) ``` GET https://data.vike.io/v1/hyperliquid/leaderboard/flow-adjusted ``` Ranked on **fills-derived realized PnL**, with external flow reported beside it. Truncated: `traders` holds 3 rows (`limit=3`); the first is shown in full, including the deposit and withdrawal figures and the `flow_unbalanced` flag described below. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/leaderboard/flow-adjusted?limit=3&window=30d" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `1d` \| `7d` \| `30d` \| `all` . Default `30d` Default 25, max 500 Keyset cursor ```json title="Example response" { "traders": [ { "wallet": "0x9bbb821a51d9c0ef24dd8592e9caad7fc469bbf2", "rank": 1, "account_value": 0.0, "total_realized_pnl": 2152061.802894, "cum_realized_pnl": 4018227.3687610077, "roi": 0.0, "pnl_pct": null, "total_volume": 45909810.173497, "total_fee": 3873.5595550000003, "trade_count": 10, "win_count": 7, "loss_count": 3, "win_rate": 0.699999988079071, "long_win_rate": 0.6000000238418579, "short_win_rate": 0.800000011920929, "long_pnl": 1163849.598942, "short_pnl": 988212.203952, "profit_factor": 32.784751892089844, "expectancy": 215206.18028939998, "max_drawdown_pct": 154.60000610351562, "current_drawdown_pct": 100.0, "calmar_ratio": 0.0, "recovery_factor": 999.0, "max_win_streak": 7, "max_loss_streak": 3, "avg_leverage": 0.0, "avg_duration_seconds": 82171, "size_cohort": "shrimp", "pnl_cohort": "3x smart", "pnl_cohort_tag": "consistent_winner", "style_cohort_tag": "whale_hunter", "attributed": { "trading_pnl_total": -225513.68282699987, "realized_trading_pnl": -296967.9990569999, "delta_unrealized_pnl": 71454.31623000004, "funding_paid_usd": 0.0, "delta_account_value": -858691.0119069999, "account_value_eod": 0.0, "active_days": 2, "last_day": "2026-08-24", "perps_trading_pnl": -225513.6828269998 }, "flow": { "deposit_usd": 0.0, "withdraw_usd": 0.0, "net_external_flow_usd": -633177.32908, "net_total_flow_usd": -633177.32908 }, "flow_unbalanced": true } ], "totalCount": 145165, "nextCursor": "eyJrIjoiZmxvd2FkanwzMGQiLCJyIjozLCJ2IjoxMTc5NzcxLjYzNzU2Mjk5OTcsInciOiIweDAwNGVkY2Q0MDM2MGUyOTNlNGNmMjYwZDJlYmRmOGM3MDc2YzFiYjgifQ", "ranked_by": "total_realized_pnl (fills-derived; excludes deposits by construction)" } ``` An account-value delta is not a trading result: a wallet that deposits $1M and loses $50k reads as +$950k on any equity-delta ranking. The rank here comes from the sum of closed round-trip PnL, which excludes deposits **by construction** — there is no flow term in it to corrupt. Deposits, withdrawals and the daily attribution figures come back in the same row, so a caller can see what was excluded rather than trust that something was. ⚠ **`flow_unbalanced` is a per-row honesty flag, not a filter.** It is true when the wallet had a `send` in the window, which is when the *attributed* block beside the rank is unreliable — the ledger records `send` one-sided (measured 2026-08-04: 1,010,263 negative rows against 835,295 positive over 30 days, netting −$1.27B, while `spotTransfer`, `internalTransfer` and `subAccountTransfer` each net about 0). **The rank itself does not depend on attribution and is unaffected.** The flag is reported and never used to filter: active traders move money constantly, and excluding them would empty the board rather than clean it. # /leaderboard (/docs/endpoints/data/hyperliquid/leaderboard) ``` GET https://data.vike.io/v1/hyperliquid/leaderboard ``` The trader leaderboard carrying the full risk-metric set — drawdown, Calmar, recovery factor, profit factor, expectancy and streaks beside PnL. A wallet up 300% through a 70% drawdown is not the same find as one up 300% without, and a PnL-only board cannot tell them apart. Truncated: `traders` holds 3 rows (`limit=3`); the first is shown in full, so the whole risk-metric set is visible. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/leaderboard?limit=3&window=30d" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `1d` \| `7d` \| `30d` \| `all` . Default `30d` One of the 20 keys below. Default `pnl` `asc` \| `desc` . Default `desc` One label on `axis` `size` \| `pnl` \| `pnl_abs` . Default `size` Minimum closed trades. Default 0 (no floor here) Minimum win rate Minimum total volume Default 25, max 500 Keyset cursor ```json title="Example response" { "traders": [ { "wallet": "0x9bbb821a51d9c0ef24dd8592e9caad7fc469bbf2", "rank": 1, "account_value": 0.0, "total_realized_pnl": 2152061.802894, "cum_realized_pnl": 4018227.3687610077, "roi": 0.0, "pnl_pct": null, "total_volume": 45909810.173497, "total_fee": 3873.5595550000003, "trade_count": 10, "win_count": 7, "loss_count": 3, "win_rate": 0.699999988079071, "long_win_rate": 0.6000000238418579, "short_win_rate": 0.800000011920929, "long_pnl": 1163849.598942, "short_pnl": 988212.203952, "profit_factor": 32.784751892089844, "expectancy": 215206.18028939998, "max_drawdown_pct": 154.60000610351562, "current_drawdown_pct": 100.0, "calmar_ratio": 0.0, "recovery_factor": 999.0, "max_win_streak": 7, "max_loss_streak": 3, "avg_leverage": 0.0, "avg_duration_seconds": 82171, "size_cohort": "shrimp", "pnl_cohort": "3x smart", "pnl_cohort_tag": "consistent_winner", "style_cohort_tag": "whale_hunter" } ], "totalCount": 414029, "nextCursor": "eyJrIjoiMzBkfHBubHxkZXNjfHNpemV8IiwiciI6MywidiI6MTE3OTc3MS42Mzc1NjI5OTk3LCJ3IjoiMHgwMDRlZGNkNDAzNjBlMjkzZTRjZjI2MGQyZWJkZjhjNzA3NmMxYmI4In0" } ``` `sort` is a closed allow-list. An unknown key gets a **400, not a clamped default** — sorting by something other than what was asked for is a wrong answer that looks like a right one. The 20 accepted keys: `pnl`, `realized_pnl`, `roi`, `volume`, `trades`, `win_rate`, `long_win_rate`, `short_win_rate`, `account_value`, `profit_factor`, `expectancy`, `calmar`, `recovery_factor`, `max_drawdown`, `current_drawdown`, `avg_leverage`, `avg_duration`, `fees`, `win_streak`, `loss_streak`. Both cohort labels are returned regardless of which axis you filtered on. `pnl_pct` is a null-guarded ROI: the stored `roi` is raw even when equity is a rounding error (values like 4.8e10 occur), so `pnl_pct` is `null` below $1 of equity and a client can display one column without special-casing. # /liquidations/risk (/docs/endpoints/data/hyperliquid/liquidations-risk) ``` GET https://data.vike.io/v1/hyperliquid/liquidations/risk ``` Share of each coin's open interest sitting close to liquidation, optionally for one cohort. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/liquidations/risk?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `size` \| `pnl` \| `pnl_abs` . Default `size` One label on that axis Default 100, max 500 ```json title="Example response" [ { "asset": "xyz:SHAZ", "total_value_usd": 3730692.188159999, "risk_value_usd": 1715452.21152, "percent_risk": 0.45982142857142866, "positions_count": 176, "at_risk_count": 12, "cohort_axis": "size", "cohort": null }, { "asset": "para:LRCX", "total_value_usd": 221490.99839999998, "risk_value_usd": 97883.91440000001, "percent_risk": 0.441931794551882, "positions_count": 22, "at_risk_count": 4, "cohort_axis": "size", "cohort": null }, { "asset": "xyz:BB", "total_value_usd": 8408334.176679997, "risk_value_usd": 3530097.7598200003, "percent_risk": 0.4198331899807826, "positions_count": 325, "at_risk_count": 83, "cohort_axis": "size", "cohort": null } ] ``` # /liquidations (/docs/endpoints/data/hyperliquid/liquidations) ``` GET https://data.vike.io/v1/hyperliquid/liquidations ``` Realized liquidation fills — what actually got liquidated. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/liquidations?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker One wallet `long` \| `short` ISO-8601 UTC bounds Default 100, max 5000 Keyset cursor ```json title="Example response" { "liquidations": [ { "liq_ts": "2026-09-03T07:54:00+00:00", "wallet": "0xffbdde0c5392ae87f4a56c2095787e96fb241546", "asset": "BTC", "side": "long", "size_token": 0.05124, "size_usd": 3997.28364, "mark_price": 78011.0, "size_cohort": null, "pnl_cohort": "unknown", "pnl_cohort_basis": "point_in_time" }, { "liq_ts": "2026-09-03T07:54:00+00:00", "wallet": "0xfd8b9b4d81d5783dd525d8af2a3566428b6a75c9", "asset": "BTC", "side": "long", "size_token": 0.04319, "size_usd": 3369.29509, "mark_price": 78011.0, "size_cohort": null, "pnl_cohort": "3x winner", "pnl_cohort_basis": "point_in_time" }, { "liq_ts": "2026-09-03T07:54:00+00:00", "wallet": "0xfc6a4b0024249e6450723cdcd25f76ad2e565980", "asset": "SUI", "side": "short", "size_token": 313.8, "size_usd": 243.58725, "mark_price": 0.77625, "size_cohort": "shrimp", "pnl_cohort": "3x winner", "pnl_cohort_basis": "point_in_time" } ], "label_computed": { "grading": "realized", "when": "at the row's own hour where an hourly label exists for it; at query time for the rows it does not (see caveat)", "lookahead": false, "resolution": "hourly", "source": "hourly point-in-time label set (fallback: live wallet realized-PnL ladder)", "detail": "Each row's pnl_cohort is the realized ladder as it stood at the liquidation's own hour, read from the hourly point-in-time label set; pnl_cohort_basis='point_in_time' on those rows. Rows that set does not cover fall back to the live ladder — today's label stamped on a historical event, which IS lookahead — and declare it with pnl_cohort_basis='current'. A wallet in neither source carries null for both fields.", "caveat": "Two windows have no point-in-time label and always fall back: rows before 2026-04-07 (liquidations reach back to 2026-01-01, point-in-time labels start 2026-04-07), and the newest hours not yet labelled. In steady state that is only the current incomplete hour — a label is published at hour close — but a backfill or recovery can temporarily widen it (observed 2026-08-21: labels 7h45m behind the wall clock for the duration), so the trailing window is however far the newest label lags now, not a constant." }, "nextCursor": "eyJ0cyI6IjIwMjYtMDktMDMgMDc6NTQ6MDAuMDAwIiwidyI6IjB4ZmM2YTRiMDAyNDI0OWU2NDUwNzIzY2RjZDI1Zjc2YWQyZTU2NTk4MCJ9" } ``` Each row's `pnl_cohort` is **point-in-time** — the label as it stood at the liquidation's own hour — wherever an hourly label exists for it, and the query-time label where one does not. `pnl_cohort_basis` says which, per row. ⚠ The envelope's `label_computed` block declares the two windows the hourly labelling does **not** cover: rows before 2026-04-07, and the newest hours not yet labelled. Rows in those windows carry a current label, not a point-in-time one. # /ohlcv/export (/docs/endpoints/data/hyperliquid/ohlcv-export) ``` GET https://data.vike.io/v1/hyperliquid/ohlcv/export ``` Streams the same single page as NDJSON: one header line naming what answered (`symbol`, `asset`, `market`, `dex`, `interval`, `tz`, `source`), then one line per bar. Sent with a `Content-Disposition` attachment filename. Lines arrive as they are produced rather than buffered until the response is complete, and a stream is allowed to stay open for up to 300 seconds — long enough for the largest page this route will return. Shown as sent — NDJSON, one object per line rather than a single JSON document: the header line first, then one line per bar. Nothing is truncated: all 6 lines are shown — the header plus the 5 bars `limit=5` asked for. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/ohlcv/export?symbol=BTC&interval=1h&limit=5" \ -H "X-API-KEY: $VIKE_API_KEY" ``` As on [`/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv) As on [`/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv) . Default `1h` As on [`/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv) . Default `perp` As on [`/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv) . Default `HL` As on [`/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv) Defaults to the maximum, 5000 ```json title="Example response" {"symbol":"BTC","asset":"BTC","market":"perp","dex":"HL","interval":"1h","tz":"UTC","source":"1h fills rollup"} {"ts":1788408000000,"open":77685.0,"high":77826.0,"low":77518.0,"close":77761.0,"volume":1638.383539999999,"volume_usd":127187806.34586997,"n_trades":11502} {"ts":1788411600000,"open":77767.0,"high":77827.0,"low":77090.0,"close":77799.0,"volume":2381.401280000001,"volume_usd":184414000.1971202,"n_trades":20200} {"ts":1788415200000,"open":77800.0,"high":77839.0,"low":77679.0,"close":77822.0,"volume":742.5236199999999,"volume_usd":57737530.938200004,"n_trades":11927} {"ts":1788418800000,"open":77823.0,"high":78177.0,"low":77569.0,"close":77635.0,"volume":2354.81572,"volume_usd":183533988.69679,"n_trades":23209} {"ts":1788422400000,"open":77636.0,"high":78019.0,"low":77636.0,"close":78018.0,"volume":415.8346900000001,"volume_usd":32390151.983540002,"n_trades":3954} ``` ⚠ **Billed 2 credits up front**, before the first byte. The response is a stream, so the row count is unknown at the moment the charge is recorded — it is a flat 2 credits whatever the page turns out to hold, and it is not reconciled afterwards. ⚠ It is **not** an unbounded whole-window export, despite the name. It streams exactly the same single clamped page [`/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv) returns — only the encoding differs. To cover a window wider than one page, walk it yourself with `start` / `end`. # /ohlcv (/docs/endpoints/data/hyperliquid/ohlcv) ``` GET https://data.vike.io/v1/hyperliquid/ohlcv ``` Hyperliquid candles, derived from executed fills. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/ohlcv?symbol=BTC&interval=1h&limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Perp ticker ( `BTC` ), spot token ( `HYPE` ) or raw index ( `@107` ) `1m` \| `5m` \| `15m` \| `30m` \| `1h` \| `4h` \| `1d` , or `Nt` for N-trade bars. Default `1h` `perp` \| `spot` . Default `perp` HL core or a HIP-3 perp DEX. Default `HL` — see below ISO-8601 UTC or epoch ms, inclusive ISO-8601 UTC or epoch ms, exclusive Default 500, clamped to 5000 ```json title="Example response" { "symbol": "BTC", "asset": "BTC", "market": "perp", "dex": "HL", "interval": "1h", "tz": "UTC", "source": "1h fills rollup", "candles": [ { "ts": 1788415200000, "open": 77800.0, "high": 77839.0, "low": 77679.0, "close": 77822.0, "volume": 742.5236199999999, "volume_usd": 57737530.938200004, "n_trades": 11927 }, { "ts": 1788418800000, "open": 77823.0, "high": 78177.0, "low": 77569.0, "close": 77635.0, "volume": 2354.81572, "volume_usd": 183533988.69679, "n_trades": 23209 }, { "ts": 1788422400000, "open": 77636.0, "high": 78019.0, "low": 77636.0, "close": 77993.0, "volume": 398.0436600000001, "volume_usd": 31002407.180790003, "n_trades": 3884 } ] } ``` **There is no `cursor`.** The engine returns one page clamped to 5000 bars; page by moving `start`/`end` yourself. ⚠ **Do not hard-code the `dex` list.** The route's own parameter description enumerates `HL | XYZ | PARA | MKTS | HYNA`, and that is a partial list: builder DEXs are deployed permissionlessly, so the accepted set is refreshed from the live deployments (within 10 minutes) rather than fixed. An earlier three-hour sample saw only five of the nine DEXs a static list would have carried — which is exactly how a hard-coded list 400s a venue that is live and trading. An unknown `dex` returns a 400 **naming the known set**; ask for one and read the answer. ⚠ **An unsupported `interval` is a 400, never a default.** Tick bars (`Nt`, 1 to 100,000 trades) and time bars are not interchangeable downstream: time bars come from the pre-aggregated minute/hour rollups, tick bars have to walk the raw fill history, **which is retained 12 months where the rollups are not**. The response's `source` field names which of the two answered, so a series that stopped early is distinguishable from an asset that stopped trading. This is the API's crypto candle surface — Hyperliquid's own bars, off the fills our node sees. [`/ohlcv/export`](/docs/endpoints/data/hyperliquid/ohlcv-export) streams the same single page as NDJSON. # /positions/coins (/docs/endpoints/data/hyperliquid/positions-coins) ``` GET https://data.vike.io/v1/hyperliquid/positions/coins ``` Market-wide open interest per coin at the latest snapshot, with the venue's own OI alongside our aggregate so the two can be reconciled. No parameters. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/positions/coins" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Truncated: the array holds **313** coins; the first 3 are shown. ```json title="Example response" [ { "coin": "BTC", "total_value": 3026943358.0, "total_value_long": 1518666445.0, "total_value_short": 1508276913.0, "count": 36436, "count_long": 22334, "count_short": 14102, "venue_open_interest_usd": 3010625139.0 }, { "coin": "ETH", "total_value": 2213433684.0, "total_value_long": 1107299108.0, "total_value_short": 1106134576.0, "count": 17829, "count_long": 11830, "count_short": 5999, "venue_open_interest_usd": 2217262604.0 }, { "coin": "HYPE", "total_value": 1856962139.0, "total_value_long": 928654781.4, "total_value_short": 928307357.8, "count": 18134, "count_long": 13648, "count_short": 4486, "venue_open_interest_usd": 1861716866.0 } ] ``` # /positions/match (/docs/endpoints/data/hyperliquid/positions-match) ``` GET https://data.vike.io/v1/hyperliquid/positions/match ``` "Who else is in this trade" — open positions matching a coin/side/leverage/entry profile, the reverse lookup from a shared position card. Runs the same implementation as the `hl_position_match` tool on the [Data MCP server](/docs/ai/data). ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/positions/match?coin=BTC&side=long&leverage=40&entry_price=78001.68&mark_price=77710&limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker, e.g. `BTC` `long` \| `short` Numeric, positive. Matched within ±0.5 absolute Numeric, positive Ranks the matches. **Defaults to `entry_price`** when omitted Declared default 50, declared max 500 — see below ```json title="Example response" { "matches": [ { "address": "0x0000005178d55139ba21423218ea7290a7ccdc81", "coin": "BTC", "side": "long", "snapshot_ts": 1788422340, "size_token": 0.00467, "size_usd": 362.90569999999997, "entry_price": 78001.6773019272, "mark_price": 77710.0, "leverage": 40.0, "liquidation_price": 77064.4662131028, "unrealized_pnl": -1.362132999999992, "entry_dist_pct": 0.0 }, { "address": "0xf94d1cfd380bbaa0d7ca33e00700e7e6be836ab1", "coin": "BTC", "side": "long", "snapshot_ts": 1788422340, "size_token": 0.035, "size_usd": 2719.8500000000004, "entry_price": 78001.85714285714, "mark_price": 77710.0, "leverage": 40.0, "liquidation_price": 0.0, "unrealized_pnl": -10.215000000000074, "entry_dist_pct": 0.0002 }, { "address": "0x76d81fdfc8226f371529d596f52d208279127663", "coin": "BTC", "side": "long", "snapshot_ts": 1788422340, "size_token": 0.00015, "size_usd": 11.6565, "entry_price": 78002.0, "mark_price": 77710.0, "leverage": 40.0, "liquidation_price": 77077.3805907173, "unrealized_pnl": -0.0438, "entry_dist_pct": 0.0004 } ], "match_count": 3, "tolerance_used": "tight", "query": { "coin": "BTC", "side": "long", "leverage": 40.0, "entry_price": 78001.68, "mark_price_hint": 77710.0 } } ``` ⚠ **`limit` is documented as max 500 and clamped to 50.** The parameter is accepted up to 500 and then clamped into 1–50. A larger request is served at 50, not rejected. **It walks three widening tolerance bands** on entry price — tight 0.05%, then 0.5%, then a 1.0% fallback — and returns the first band that matches, ranked by mark-price proximity. So a match is not necessarily a 0.05% match; check the reported band. ⚠ **A 15-minute freshness gate is why this returns fewer rows than the position history holds.** The latest-state view keeps a wallet's last-known row forever — 89.8% of it measured more than a day stale, the oldest ghost 2026-04-02 — so without the gate this matched months-closed positions as open. The gate is anchored on the newest snapshot **batch** rather than on the wall clock, so ingestion lag cannot empty the result. # /positions (/docs/endpoints/data/hyperliquid/positions) ``` GET https://data.vike.io/v1/hyperliquid/positions ``` Position rows, current or historical. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/positions?coin=BTC&limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker One wallet One label on `axis` `size` \| `pnl` \| `pnl_abs` . Default `size` Boolean, default `true` . Latest open positions only ISO-8601 UTC. Setting either switches to history Default 100, max 5000 Keyset cursor ```json title="Example response" { "positions": [ { "wallet": "0x0000000000bdc2fd416def2d8688069052eb0f87", "asset": "BTC", "direction": "long", "size_token": 0.00553, "size_usd": 429.7363, "entry_price": 69452.7555153707, "mark_price": 77710.0, "liquidation_price": 0.0, "unrealized_pnl": 45.662561999999994, "leverage": 0.0, "leverage_type": "cross", "margin_used": 429.7363, "size_cohort": "shrimp", "pnl_cohort": "rekt", "pnl_abs_cohort": "-$10k-$0", "liq_progress": 0.0, "dex_name": "HL", "snapshot_ts": "2026-09-03T07:59:00+00:00", "pnl_cohort_as_of": null, "pnl_cohort_basis": "current", "opened_at": "2026-04-02T08:36:00+00:00" }, { "wallet": "0x0000005178d55139ba21423218ea7290a7ccdc81", "asset": "BTC", "direction": "long", "size_token": 0.00467, "size_usd": 362.90569999999997, "entry_price": 78001.6773019272, "mark_price": 77710.0, "liquidation_price": 77064.4662131028, "unrealized_pnl": -1.362132999999992, "leverage": 40.0, "leverage_type": "isolated", "margin_used": 9.072642499999999, "size_cohort": "shrimp", "pnl_cohort": "3x loser", "pnl_abs_cohort": "-$10k-$0", "liq_progress": 0.31121836415002746, "dex_name": "HL", "snapshot_ts": "2026-09-03T07:59:00+00:00", "pnl_cohort_as_of": null, "pnl_cohort_basis": "current", "opened_at": "2026-04-02T08:36:00+00:00" }, { "wallet": "0x0000fb4e763a5a617e12930c67877e9d25fcf460", "asset": "BTC", "direction": "long", "size_token": 1.6409, "size_usd": 127514.339, "entry_price": 63987.60771101226, "mark_price": 77710.0, "liquidation_price": 52473.2111772561, "unrealized_pnl": 22517.073506999986, "leverage": 5.0, "leverage_type": "isolated", "margin_used": 25502.8678, "size_cohort": "2x fish", "pnl_cohort": "2x smart", "pnl_abs_cohort": "$0-$10k", "liq_progress": 0.0, "dex_name": "HL", "snapshot_ts": "2026-09-03T07:59:00+00:00", "pnl_cohort_as_of": null, "pnl_cohort_basis": "current", "opened_at": "2026-04-10T12:37:00+00:00" } ], "nextCursor": "eyJhIjoiQlRDIiwiZCI6MSwibSI6ImxhdGVzdCIsInQiOiIyMDI2LTA5LTAzIDA3OjU5OjAwIiwidyI6IjB4MDAwMGZiNGU3NjNhNWE2MTdlMTI5MzBjNjc4NzdlOWQyNWZjZjQ2MCJ9", "label_computed": { "grading": "realized", "label_basis": "current", "when": "at query time", "lookahead": false, "resolution": "none — one label per wallet, today's", "source": "live wallet realized-PnL ladder", "detail": "Current mode serves the latest snapshot labelled with the live dictionary: the label and the row describe the same instant, so a live row labelled live has nothing to look ahead over. The same dictionary applied to a HISTORICAL row would be lookahead, which is why historical mode reads the hourly point-in-time labels instead." } } ``` ⚠ **Passing `start`/`end` changes which table answers.** `open_only=true` reads the latest-state table; a time bound switches to the historical snapshot table, which is **8.7 billion rows** — bound it by `coin` or `wallet`, or expect it to be slow. ⚠ **A historical `pnl` or `pnl_abs` cohort filter requires `coin` or `wallet` alongside it**, because it is answered from the same hourly label table. Every row declares its own label provenance: `pnl_cohort_basis` (`point_in_time` | `current`) and `pnl_cohort_as_of` (the ISO hour the label was computed at, or null). Historical rows carry the point-in-time label from the hourly label table — not today's dictionary standing — with the current unlabelled tail (the newest hour or two) falling back to the dictionary and **saying so per row**. The envelope's `label_computed` block states the mode's source once. # /segments/{cohort}/summary (/docs/endpoints/data/hyperliquid/segments-cohort-summary) ``` GET https://data.vike.io/v1/hyperliquid/segments/{cohort}/summary ``` Aggregate positioning for one cohort at the latest snapshot. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/segments/shrimp/summary?axis=size" \ -H "X-API-KEY: $VIKE_API_KEY" ``` A label or slug from [`/cohorts`](/docs/endpoints/data/hyperliquid/cohorts) / [`/segments`](/docs/endpoints/data/hyperliquid/segments) `size` \| `pnl` \| `pnl_abs` . Default `size` . `trade_size` is rejected `24h` \| `7d` \| `30d` \| `all` . Default `all` ```json title="Example response" { "axis": "size", "cohort": "shrimp", "opened_within": "all", "snapshot_ts": "2026-09-03T07:59:00Z", "total_long_usd": 65653615.07, "total_short_usd": 36290036.09, "total_value": 101943651.2, "bias": 0.2880373485, "n_positions": 177573, "n_wallets": 90696, "n_wallets_in_profit": 47253, "pct_wallets_in_profit": 0.5210042339, "total_margin_used": 24105649.81, "total_equity": 9911731.106, "n_wallets_with_equity": 68060, "exposure_ratio": 4.229035597, "total_unrealized_pnl": -994085.9799, "by_side": { "long": { "value_usd": 65653615.07, "share": 0.6440186743, "n_positions": 125103, "n_wallets": 68812, "margin_used": 14244413.93, "unrealized_pnl": -370747.1081 }, "short": { "value_usd": 36290036.09, "share": 0.3559813257, "n_positions": 52470, "n_wallets": 29885, "margin_used": 9861235.882, "unrealized_pnl": -623338.8718 } } } ``` ⚠ `axis` defaults to `size`, so a valid `pnl` label sent without its axis reads back as "not on the size ladder" — which describes the default, not your mistake. The 400 names the label's real home. # /segments (/docs/endpoints/data/hyperliquid/segments) ``` GET https://data.vike.io/v1/hyperliquid/segments ``` The detailed catalogue: every bucket on every axis with its boundaries, slug, rank, `basis` and a `groupable` flag. Takes no parameters. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/segments" \ -H "X-API-KEY: $VIKE_API_KEY" ``` Truncated: the array holds **44** rows — every bucket on all four axes (`size`, `pnl`, `pnl_abs`, `trade_size`); the first 3 are shown. ```json title="Example response" [ { "axis": "size", "cohort": "4x whale", "slug": "4x_whale", "rank": 1, "basis": "account_value_usd", "applies_to": "wallet", "groupable": true, "min_usd": 10000000.0, "max_usd": null, "percentile_min": null, "percentile_max": null, "min_closed_trades": null }, { "axis": "size", "cohort": "3x whale", "slug": "3x_whale", "rank": 2, "basis": "account_value_usd", "applies_to": "wallet", "groupable": true, "min_usd": 5000000.0, "max_usd": 10000000.0, "percentile_min": null, "percentile_max": null, "min_closed_trades": null }, { "axis": "size", "cohort": "2x whale", "slug": "2x_whale", "rank": 3, "basis": "account_value_usd", "applies_to": "wallet", "groupable": true, "min_usd": 2500000.0, "max_usd": 5000000.0, "percentile_min": null, "percentile_max": null, "min_closed_trades": null } ] ``` ⚠ **Filter on `groupable`.** It is true for the per-wallet ladders and false for `trade_size`, which buckets a position rather than a wallet and is rejected as `?axis=` by [`/heatmap`](/docs/endpoints/data/hyperliquid/heatmap) and [`/segments/{cohort}/summary`](/docs/endpoints/data/hyperliquid/segments-cohort-summary). A client generating code off this endpoint that assumes every published axis is a valid grouping key will emit calls that 400. # /stats/volume (/docs/endpoints/data/hyperliquid/stats-volume) ``` GET https://data.vike.io/v1/hyperliquid/stats/volume ``` Hourly perp volume — the whole venue, or one coin. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/stats/volume?coin=BTC&limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` HL coin ticker. Omit for the whole venue ISO-8601 UTC bounds Default 500, max 5000 Keyset cursor ```json title="Example response" { "volume": [ { "ts": "2026-09-03T08:00:00+00:00", "volume": 61952806.20848002, "n_fills": 7662, "coin": "BTC" }, { "ts": "2026-09-03T07:00:00+00:00", "volume": 367067977.39358, "n_fills": 46418, "coin": "BTC" }, { "ts": "2026-09-03T06:00:00+00:00", "volume": 115475061.87640005, "n_fills": 23854, "coin": "BTC" } ], "nextCursor": "eyJ0cyI6IjIwMjYtMDktMDMgMDY6MDA6MDAifQ" } ``` # /stats (/docs/endpoints/data/hyperliquid/stats) ``` GET https://data.vike.io/v1/hyperliquid/stats ``` Ecosystem time series: perp equity, open interest, position counts. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/stats?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` ISO-8601 UTC bounds Default 500, max 5000 Keyset cursor ```json title="Example response" { "metrics": [ { "ts": "2026-09-03T07:00:00+00:00", "total_perp_equity": 2178980170.7294016, "count_wallets": 125165, "count_open_positions": 362781, "count_long": 237331, "count_short": 125450, "open_interest": 13869169131.74862, "open_interest_long": 6939683497.517567, "open_interest_short": 6929485634.231054, "count_positions_in_profit": 189633 }, { "ts": "2026-09-03T06:00:00+00:00", "total_perp_equity": 2187765497.7367554, "count_wallets": 124626, "count_open_positions": 361533, "count_long": 236634, "count_short": 124899, "open_interest": 13875586056.264666, "open_interest_long": 6938701711.02817, "open_interest_short": 6936884345.236498, "count_positions_in_profit": 191147 }, { "ts": "2026-09-03T05:00:00+00:00", "total_perp_equity": 2175650033.332754, "count_wallets": 124837, "count_open_positions": 362198, "count_long": 237231, "count_short": 124967, "open_interest": 13829590200.787807, "open_interest_long": 6914317028.5549555, "open_interest_short": 6915273172.232853, "count_positions_in_profit": 191243 } ], "nextCursor": "eyJ0cyI6IjIwMjYtMDktMDMgMDU6MDA6MDAifQ" } ``` # /traders (/docs/endpoints/data/hyperliquid/traders) ``` GET https://data.vike.io/v1/hyperliquid/traders ``` Top HL perp traders by the requested metric over a window. Runs the same implementation as the `hl_perp_top_traders` tool on the [Data MCP server](/docs/ai/data), deliberately: a second ranking that disagreed with the one the assistant quotes would be worse than a near-duplicate route. [`/leaderboard`](/docs/endpoints/data/hyperliquid/leaderboard) is the richer, cursor-paged version. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/traders?size=3&window=30d" \ -H "X-API-KEY: $VIKE_API_KEY" ``` `1d` \| `7d` \| `30d` \| `all` . Effective default `30d` `pnl` \| `roi` \| `win_rate` \| `volume` \| `trade_count` \| `drawdown` \| `account_value` \| `long_pnl` \| `short_pnl` . Default `pnl` `asc` \| `desc` . Default `desc` Minimum closed trades — see the floor below **Percent, 0–100** , not a fraction Minimum total volume Declared max 100, clamped to 20 — see below ```json title="Example response" { "traders": [ { "address": "0x020ca66c30bec2c4fe3861a94e4db4a498a35872", "total_realized_pnl": 771490.2187940001, "roi_pct": 1752.226, "win_rate_pct": 67.88, "long_win_rate_pct": 72.16, "short_win_rate_pct": 61.76, "win_count": 112, "loss_count": 53, "trade_count": 165, "total_volume": 132982230.79802103, "long_pnl": 569640.7646920001, "short_pnl": 201849.454102, "max_drawdown_pct": 10000.0, "account_value": 4402914.84891, "avg_leverage": 0.0 }, { "address": "0x25773676902085225ae5751075e4161369334897", "total_realized_pnl": 720180.041582607, "roi_pct": 4309.738, "win_rate_pct": 52.71, "long_win_rate_pct": 52.63, "short_win_rate_pct": 52.78, "win_count": 13157, "loss_count": 11805, "trade_count": 24962, "total_volume": 2447047515.9331417, "long_pnl": 391309.8679779798, "short_pnl": 328870.17360462697, "max_drawdown_pct": 10000.0, "account_value": 1671052.77246, "avg_leverage": 0.0 }, { "address": "0xfc27136e42af1732ddc9ce2605ea9bff1b959d9d", "total_realized_pnl": 449283.75011699996, "roi_pct": 261.879, "win_rate_pct": 50.26, "long_win_rate_pct": 49.96, "short_win_rate_pct": 50.54, "win_count": 2549, "loss_count": 2523, "trade_count": 5072, "total_volume": 356717539.01556593, "long_pnl": 455601.4763099999, "short_pnl": -6317.726193000033, "max_drawdown_pct": 10000.0, "account_value": 17156179.333347, "avg_leverage": 0.0 } ], "total": 18587 } ``` ⚠ **Two bounds differ from what the parameter table declares**, and both are applied silently: * `size` is documented as max 100 and clamped to **20**. Asking for 100 returns 20. * `min_trades` is floored at **3**. Omitting it, or passing 0, 1 or 2, all give 3. ⚠ **An unrecognised `sort` is not a 400 here.** It falls back to the default PnL ranking silently, so a typo returns a correct-looking answer sorted by something you did not ask for. [`/leaderboard`](/docs/endpoints/data/hyperliquid/leaderboard) rejects the same mistake outright — the two routes disagree. An omitted string parameter and an empty one (`&sort=`) are treated identically: both fall through to the default in the table above rather than being rejected. # /wallets/{address}/builders (/docs/endpoints/data/hyperliquid/wallets-address-builders) ``` GET https://data.vike.io/v1/hyperliquid/wallets/{address}/builders ``` Reverse lookup: which builders this wallet's flow is routed through. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/wallets/0x25773676902085225ae5751075e4161369334897/builders?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` The wallet `1d` \| `7d` \| `30d` \| `all` . Default `all` Default 50, max 500 Keyset cursor ```json title="Example response" { "wallet": "0x25773676902085225ae5751075e4161369334897", "window": { "window": "all", "start": "2024-10-27T18:15:13+00:00", "end": "2026-09-03T08:07:49.353000+00:00" }, "builders": [ { "builder": "0xab5dbc057628bc18523c4cdfc0e1e2ebdbecb704", "assets": 42, "share_of_volume": 1.0, "fee_usd": 0.0, "volume_usd": 183419.57242000004, "share_volume_usd": 183419.57242000004, "fills": 2126, "fee_rate_bps": 0.0, "fee_usd_node": 0.0, "trader_fee_usd": 3.961064089999999, "builder_routed_deployer_fee_usd": 1.9780274800000002, "closed_pnl_usd": 4463.86290756, "active_days": 2, "crossed_fills": 966, "taker_pct": 45.437441204139226, "first_seen": "2026-09-02T19:45:15.814000+00:00", "last_seen": "2026-09-03T08:07:27.555000+00:00" } ], "nextCursor": null } ``` ⚠ `share_of_volume` is the share of the wallet's **builder-routed** volume, not of its total trading. Fills placed without a builder code never enter this dataset at all. # /wallets/{address}/fills (/docs/endpoints/data/hyperliquid/wallets-address-fills) ``` GET https://data.vike.io/v1/hyperliquid/wallets/{address}/fills ``` Raw fills — the level below round-trip trades. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/wallets/0x25773676902085225ae5751075e4161369334897/fills?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` The wallet HL coin ticker `buy` \| `sell` ISO-8601 UTC bounds Default 100, max 5000 Keyset cursor ```json title="Example response" { "wallet": "0x25773676902085225ae5751075e4161369334897", "items": [ { "ts": "2026-09-03T08:07:48.351000+00:00", "asset": "xyz:PLATINUM", "side": "sell", "direction": "sell", "size_token": 0.1, "price": 1789.3, "size_usd": 178.93, "fee": -0.000178, "fee_token": "USDC", "is_maker": true, "crossed": false, "closed_pnl": 1.387, "order_id": 534782948640, "trade_id": 629173489889533, "dex_name": "XYZ" }, { "ts": "2026-09-03T08:07:41.879000+00:00", "asset": "xyz:PLATINUM", "side": "sell", "direction": "sell", "size_token": 0.025, "price": 1789.2, "size_usd": 44.730000000000004, "fee": 0.001502, "fee_token": "USDC", "is_maker": false, "crossed": true, "closed_pnl": 0.34425, "order_id": 534782918094, "trade_id": 440416105012118, "dex_name": "XYZ" }, { "ts": "2026-09-03T08:07:41.054000+00:00", "asset": "xyz:SKHX", "side": "sell", "direction": "sell", "size_token": 0.646, "price": 1181.0, "size_usd": 762.926, "fee": -0.000762, "fee_token": "USDC", "is_maker": true, "crossed": false, "closed_pnl": 0.0, "order_id": 534782865245, "trade_id": 1107832820522232, "dex_name": "XYZ" } ], "nextCursor": "eyJpIjoxMTA3ODMyODIwNTIyMjMyLCJ0IjoiMjAyNi0wOS0wMyAwODowNzo0MS4wNTQifQ" } ``` # /wallets/{address}/funding (/docs/endpoints/data/hyperliquid/wallets-address-funding) ``` GET https://data.vike.io/v1/hyperliquid/wallets/{address}/funding ``` Funding paid and received by one wallet. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/wallets/0x25773676902085225ae5751075e4161369334897/funding?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` The wallet HL coin ticker ISO-8601 UTC bounds Default 100, max 5000 Keyset cursor ```json title="Example response" { "wallet": "0x25773676902085225ae5751075e4161369334897", "items": [ { "ts": "2026-08-28T13:00:00+00:00", "asset": "xyz:ZM", "payment_usd": -1.897955, "position_size": -95.101, "funding_rate": -0.0001990948, "dex_name": "HL" }, { "ts": "2026-08-28T13:00:00+00:00", "asset": "xyz:XYZ100", "payment_usd": 0.000018, "position_size": -0.0001, "funding_rate": 0.00000625, "dex_name": "HL" }, { "ts": "2026-08-28T13:00:00+00:00", "asset": "xyz:XLE", "payment_usd": 0.253792, "position_size": -467.0, "funding_rate": 0.000008719, "dex_name": "HL" } ], "nextCursor": "eyJhIjoieHl6OlhMRSIsInQiOiIyMDI2LTA4LTI4IDEzOjAwOjAwIn0" } ``` # /wallets/{address}/ledger (/docs/endpoints/data/hyperliquid/wallets-address-ledger) ``` GET https://data.vike.io/v1/hyperliquid/wallets/{address}/ledger ``` Deposits, withdrawals, transfers and vault events. Keyset on `(ts, hash)`. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/wallets/0x25773676902085225ae5751075e4161369334897/ledger?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` The wallet One of the 14 observed types below ISO-8601 UTC bounds Default 100, max 5000 Keyset cursor ```json title="Example response" { "wallet": "0x25773676902085225ae5751075e4161369334897", "items": [ { "ts": "2026-09-03T00:31:21.150000+00:00", "event_type": "send", "amount_usd": -10000.0, "fee": 0.0, "token": "USDC", "counterparty": null, "hash": "0x1648cc076d05cb6d17c204438ee76502036f00ed0808ea3fba11775a2c09a557" }, { "ts": "2026-09-03T00:31:21.015000+00:00", "event_type": "send", "amount_usd": -10000.0, "fee": 0.0, "token": "USDC", "counterparty": null, "hash": "0xaec6e68724b1831cb04004438ee76302012c006cbfb4a1ee528f91d9e3b55d07" }, { "ts": "2026-09-03T00:31:20.817000+00:00", "event_type": "send", "amount_usd": -10000.0, "fee": 0.0, "token": "USDC", "counterparty": null, "hash": "0x5bc25cde8ff2319b5d3c04438ee76002034f00c42af5506dff8b08314ef60b85" } ], "nextCursor": "eyJoIjoiMHg1YmMyNWNkZThmZjIzMTliNWQzYzA0NDM4ZWU3NjAwMjAzNGYwMGM0MmFmNTUwNmRmZjhiMDgzMTRlZjYwYjg1IiwidCI6IjIwMjYtMDktMDMgMDA6MzE6MjAuODE3In0" } ``` `event_type` is validated against a fixed set rather than passed through, because an unknown value would return an empty list indistinguishable from "this wallet never did that". The accepted set is: `deposit`, `withdraw`, `send`, `spotTransfer`, `internalTransfer`, `subAccountTransfer`, `accountClassTransfer`, `vaultDeposit`, `vaultWithdraw`, `vaultCreate`, `vaultDistribution`, `vaultLeaderCommission`, `borrowLend`, `rewardsClaim`. ⚠ `send` is recorded **one-sided** — the outflow is seen while the matching inflow is not. That is the same defect the `flow_unbalanced` flag on [`/leaderboard/flow-adjusted`](/docs/endpoints/data/hyperliquid/leaderboard-flow-adjusted) reports. # /wallets/{address}/trades/summary (/docs/endpoints/data/hyperliquid/wallets-address-trades-summary) ``` GET https://data.vike.io/v1/hyperliquid/wallets/{address}/trades/summary ``` Trade statistics for one wallet, overall **and per coin**. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/wallets/0x25773676902085225ae5751075e4161369334897/trades/summary?window=all" \ -H "X-API-KEY: $VIKE_API_KEY" ``` The wallet `1d` \| `7d` \| `30d` \| `all` . Default `all` Truncated: `by_coin` holds **730** rows and the full response is **686,955 bytes** — a per-wallet summary is not a small object. `overall` is complete; one `by_coin` row is shown. ```json title="Example response" { "wallet": "0x25773676902085225ae5751075e4161369334897", "window": "all", "overall": { "trade_count": 194059, "closed_trade_count": 143614, "open_trade_count": 50445, "fill_count": 12950561, "wins": 93514, "losses": 84375, "win_rate": 0.48188437537037704, "long_trades": 96776, "short_trades": 97283, "long_win_rate": 0.4904108456642143, "short_win_rate": 0.4734023416218661, "net_pnl": 4012369.098752309, "total_winning_pnl": 31301555.306204822, "total_losing_pnl": -27289186.20745252, "avg_gain": 334.7258731976477, "avg_loss": -323.42739208832614, "median_gain": 8.475151, "median_loss": -7.714446000000001, "median_duration": 139, "avg_duration": 5520.710223237289, "max_duration": 9171206, "median_size_usd": 7987.900000000001, "avg_trade_size": 52618.05303111898, "max_trade_size_usd": 22907390.75299998, "profit_factor": 1.1470314676388755, "payoff_ratio": 1.0349335937082162, "expectancy": 20.676026871994132, "total_fees": 1188580.8811811705, "total_volume": 36620085194.54977, "volume_share_pct": 100.0, "first_trade_ts": "2026-01-02T12:03:58.812000+00:00", "last_trade_ts": "2026-09-03T07:45:37.776000+00:00", "first_open_ts": "2026-01-02T12:03:58.812000+00:00", "last_close_ts": "2026-09-03T07:44:53.266000+00:00" }, "by_coin": [ { "trade_count": 26946, "closed_trade_count": 26292, "open_trade_count": 654, "fill_count": 1965539, "wins": 13367, "losses": 12757, "win_rate": 0.4960662064870482, "long_trades": 13597, "short_trades": 13349, "long_win_rate": 0.49202029859527835, "short_win_rate": 0.5001872799460634, "net_pnl": -113790.09055999969, "total_winning_pnl": 413414.5836639994, "total_losing_pnl": -527204.6742239995, "avg_gain": 30.92800057335224, "avg_loss": -41.32669704664102, "median_gain": 1.3275439999999998, "median_loss": -0.838675, "median_duration": 27, "avg_duration": 548.5271945839038, "max_duration": 6423232, "median_size_usd": 10717.174800000581, "avg_trade_size": 44350.39012839752, "max_trade_size_usd": 16950533.750400003, "profit_factor": 0.7841633503582087, "payoff_ratio": 0.7483782344968705, "expectancy": -4.222893585689887, "total_fees": 121978.92007200004, "total_volume": 5416660000.848094, "volume_share_pct": 14.791500271152469, "first_trade_ts": "2026-01-02T12:03:58.812000+00:00", "last_trade_ts": "2026-09-03T07:18:07.783000+00:00", "first_open_ts": "2026-01-02T12:03:58.812000+00:00", "last_close_ts": "2026-09-03T07:00:58.017000+00:00", "asset": "xyz:XYZ100" } ], "coin_count": 730, "net_pnl": 4012369.098752309 } ``` Carries medians as well as means, deliberately: one $4M outlier drags the mean trade size far from anything the wallet typically does, and the mean is where most leaderboards stop. # /wallets/{address}/trades (/docs/endpoints/data/hyperliquid/wallets-address-trades) ``` GET https://data.vike.io/v1/hyperliquid/wallets/{address}/trades ``` Closed round-trip trades for one wallet, newest first, cursor-paged. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/wallets/0x25773676902085225ae5751075e4161369334897/trades?limit=3" \ -H "X-API-KEY: $VIKE_API_KEY" ``` The wallet HL coin ticker, e.g. `BTC` . Omit for all ISO-8601 UTC bounds Default 100, max 5000 `nextCursor` from the previous page, verbatim ```json title="Example response" { "wallet": "0x25773676902085225ae5751075e4161369334897", "trades": [ { "asset": "io:SNDK", "direction": "short", "open_ts": "2026-09-03T07:20:22.487000+00:00", "close_ts": "2026-09-03T07:44:53.266000+00:00", "duration_seconds": 1470, "entry_price": 1545.0404275385101, "exit_price": 1544.4, "max_size_token": 3.3660999999999994, "max_size_usd": 5200.624499999999, "volume_usd": 7988.22181, "realized_pnl": -17.077995, "total_fee": 0.052302, "fill_count": 14, "is_complete": true }, { "asset": "xyz:SP500", "direction": "short", "open_ts": "2026-09-03T07:25:31.682000+00:00", "close_ts": "2026-09-03T07:44:46.388000+00:00", "duration_seconds": 1154, "entry_price": 7668.138927427963, "exit_price": 7676.2, "max_size_token": 7.4959999999999996, "max_size_usd": 57477.4812, "volume_usd": 114989.9841, "realized_pnl": -29.252252, "total_fee": 1.873843, "fill_count": 39, "is_complete": true }, { "asset": "xyz:HYUNDAI", "direction": "long", "open_ts": "2026-09-03T07:41:59.151000+00:00", "close_ts": "2026-09-03T07:44:15.479000+00:00", "duration_seconds": 136, "entry_price": 281.88, "exit_price": 281.77, "max_size_token": 2.4, "max_size_usd": 676.512, "volume_usd": 1355.04746, "realized_pnl": -0.156, "total_fee": 0.022122, "fill_count": 7, "is_complete": true } ], "nextCursor": "eyJhIjoieHl6OkhZVU5EQUkiLCJjIjoiMjAyNi0wOS0wMyAwNzo0NDoxNS40NzkiLCJkIjoxLCJvIjoiMjAyNi0wOS0wMyAwNzo0MTo1OS4xNTEifQ" } ``` # /wallets/{address} (/docs/endpoints/data/hyperliquid/wallets-address) ``` GET https://data.vike.io/v1/hyperliquid/wallets/{address} ``` One wallet in one call: equity, its cohort labels on each ladder, the risk-metric set, lifetime external flow and the open-positions block. The entry point for any per-wallet investigation. ```sh title="cURL" curl -s "https://data.vike.io/v1/hyperliquid/wallets/0x25773676902085225ae5751075e4161369334897?window=all&openedWithin=all" \ -H "X-API-KEY: $VIKE_API_KEY" ``` The wallet `1d` \| `7d` \| `30d` \| `all` . Default `all` `24h` \| `7d` \| `30d` \| `all` . Default `all` ```json title="Example response" { "wallet": "0x25773676902085225ae5751075e4161369334897", "window": "all", "metrics_updated_at": "2026-09-03T08:05:59+00:00", "has_metrics": true, "equity": { "as_of": "2026-09-03T08:04:00+00:00", "account_value": 1661040.506568, "margin_used": 753469.656636, "cash_balance_usd": 5073036.012782, "total_unrealized_pnl": -127149.9813, "num_positions": 140 }, "open_positions": { "count": 143, "notional_usd": 11949787.046935001, "unrealized_pnl": -127250.71641500016, "margin_used": 1303534.4115760627, "as_of": "2026-09-03T07:59:00+00:00", "dexes": [ "HL", "IO", "XYZ", "PARA" ], "opened_within": "all", "oldest_opened_at": "2026-04-02T08:36:00+00:00", "newest_opened_at": "2026-09-02T19:38:00+00:00", "with_known_open_ts": 143 }, "cohorts": { "size": "3x whale", "pnl": "3x smart", "pnl_tag": "drawdown_recovery", "style_tag": "streak_trader" }, "lifetime": { "cum_realized_pnl": 3917992.366811535, "cum_fills": 6417096, "last_fill_ts": "2026-09-03T08:07:48.351000+00:00", "deposit_usd": 0.0, "withdraw_usd": 0.0, "net_deposit_usd": 0.0, "vault_deposit_usd": 0.0, "vault_withdraw_usd": 0.0, "ledger_events": 457 }, "ledger_by_type": [ { "event_type": "send", "amount_usd": -11658895.529263001, "fee_usd": 0.0, "count": 457, "first_ts": "2026-01-14T19:00:57.067000+00:00", "last_ts": "2026-09-03T00:31:21.150000+00:00" } ], "metrics": { "wallet": "0x25773676902085225ae5751075e4161369334897", "account_value": 1671052.77246, "total_realized_pnl": 3716145.2005327553, "roi": 222.38347356691327, "pnl_pct": 222.38347356691327, "total_volume": 26290176002.307007, "total_fee": 930945.91746686, "trade_count": 135740, "win_count": 70654, "loss_count": 65086, "win_rate": 0.5205097794532776, "long_win_rate": 0.5239437818527222, "short_win_rate": 0.5170527696609497, "long_pnl": 1892139.8385331659, "short_pnl": 1824005.3619995913, "profit_factor": 1.2365819215774536, "expectancy": 27.376935321443607, "max_drawdown_pct": 100.0, "current_drawdown_pct": 36.20000076293945, "calmar_ratio": 2.223834753036499, "recovery_factor": 2.223834753036499, "max_win_streak": 28, "max_loss_streak": 77, "avg_leverage": 0.0, "avg_duration_seconds": 4492, "pnl_cohort_tag": "drawdown_recovery", "style_cohort_tag": "streak_trader" } } ``` ⚠ `openedWithin` bounds the **open-positions block only**. The equity, lifetime and metrics blocks are wallet-level totals with nothing per-position to filter, and bounding them would return a number that looks bounded and is not. Positions with no known open time are excluded from every bounded bucket — the same rule [`/segments`](/docs/endpoints/data/hyperliquid/segments) and [`/heatmap`](/docs/endpoints/data/hyperliquid/heatmap) apply, so cohort comparisons stay like-for-like. # On-chain REST (/docs/endpoints/data/onchain) `/v1/onchain/` answers questions about EVM addresses and ERC-20 tokens: what a wallet holds, who it trades with, where its money went, who is accumulating a token, and which labelled institution sits behind an address. Fifteen endpoints, all on `https://data.vike.io`, all behind the [API key](/docs/data/authentication) and the [shared hourly budget](/docs/data/rate-limits). The chain is a **filter, not a namespace** — `?chain=ethereum|bsc|base` on most routes, because the data behind this namespace is multi-chain by design. ⚠ One route accepts `chain` and then ignores it: [`POST /labels/batch`](/docs/endpoints/data/onchain/labels-batch) is Ethereum-only whatever you send. Every call carries the credential in a header — `X-API-Key: vk_...` or `Authorization: Bearer vk_oat_...`, either alone is enough ([Authentication](/docs/data/authentication)) — and spends one unit of the shared hourly counter ([Rate limits](/docs/data/rate-limits)): ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/counterparties?window=7d&limit=20" ``` Each endpoint has its own page, carrying its full request line in a fence at the top; the page titles are written as the path, without the method. The 15 endpoints on this namespace map one-to-one onto 15 of the tools the MCP server at `vike.io/mcp` advertises, and each pair is answered by the same query — there is no second implementation to drift. So a REST call and the matching MCP tool call return **the same payload**. What they do *not* share is the HTTP contract on top: parameter names, defaults, ceilings and billing differ, and the three sections below are about exactly those differences. ## Conventions on this namespace [#conventions-on-this-namespace] ### Omitting a parameter is not the same as passing zero [#omitting-a-parameter-is-not-the-same-as-passing-zero] A numeric parameter sent as `0` — or as an empty string — is treated exactly like one you did not send at all: the endpoint falls back to its own default, and that default is not always zero. | Parameter | Passing `0`, or omitting it, resolves to | | ------------------------------------ | ---------------------------------------- | | `min_usd` on `/trace` | **10,000 USD** | | `min_usd` on `/balances` | **1.0 USD** | | `min_usd` on `/transactions` | 0 (no floor) | | `days` on `/pnl` and `/transactions` | 30 | | `depth` / `width` on `/trace` | 2 / 5 | ⚠ The consequence worth carrying: **you cannot ask `/trace` or `/balances` for a floor of exactly zero.** `min_usd=0` selects the default floor instead of removing it. Pass a small positive number (`min_usd=0.01`) if you want everything. ### Two ceilings, and the lower one is silent [#two-ceilings-and-the-lower-one-is-silent] The `limit` a route *accepts* and the `limit` it *applies* are two different numbers. The request validator enforces the higher one; the query underneath then quietly caps at the lower one. Nothing rejects an over-cap value and nothing reports the cap — a request for 1,000 rows returns 50 and looks exactly like a wallet with only 50 counterparties. | Endpoint | Parameter | HTTP accepts | Actually applied | | ----------------------------------- | ----------------- | -------------------------- | ------------------ | | `/wallets/compare` | `limit` | ≤ 1000 (route default 100) | **≤ 50** | | `/wallets/{address}/transactions` | `limit` | ≤ 1000 (route default 100) | **≤ 500** | | `/wallets/{address}/counterparties` | `limit` | ≤ 1000 (route default 100) | **≤ 50** | | `/wallets/{address}/pnl` | `days` | any integer | **1–365** | | `/wallets/{address}/trace` | `depth` / `width` | any integer | **1–5** / **1–10** | | `/tokens/screener` | `limit` | ≤ 500 (route default 50) | **≤ 100** | | `/tokens/{token_address}/holders` | `limit` | ≤ 500 (route default 50) | **≤ 50** | ⚠ This is also where REST and MCP produce **different default row counts from the same query**. A REST route always applies its own `limit` default, even when you send none, so `/wallets/{address}/counterparties` defaults to 100 and caps at 50 rows, while the `wallet_counterparties` MCP tool defaults to 10. Same data, different page size. Set `limit` explicitly if you care. ### Credits are counted differently from MCP [#credits-are-counted-differently-from-mcp] **Every on-chain REST call logs one credit**, whatever it cost to serve — no route on this namespace charges more. Over MCP the same work is priced by tool instead, from 0 (`token_search`) to 4 (`wallet_trace`, the heaviest graph traversal). The hourly request budget is separate again and charges one unit per request on every surface, this namespace included. ### Errors [#errors] Status codes and the error envelope are namespace-wide and documented under [Errors](/docs/data/errors). The mapping: a rejected input becomes **400** carrying the reason, and any other failure becomes **503**. ⚠ **Not every failure arrives as a 4xx.** Some errors on this namespace come back as **HTTP 200** carrying a one-element array whose single entry describes the problem, so a client that branches on the status code alone will read a failure as an empty-ish success. The reachable case here is [`POST /labels/batch`](/docs/endpoints/data/onchain/labels-batch) — see that endpoint's own page. A malformed address on the two routes that reach outside ClickHouse (`/balances` and `/ens`) *does* arrive as a **400**, with a message reading `Bad EVM address: …`. The check is strict: a `0x` prefix and exactly 42 characters. ## Wallets [#wallets] Nine endpoints. `/wallets/compare` and `/wallets/discover` are literal paths rather than addresses — they take no `{address}` segment. | Endpoint | What it answers | | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | [`GET /wallets/{address}`](/docs/endpoints/data/onchain/wallets-address) | Aggregate metrics for one wallet across the 24h / 7d / 30d / 90d / all windows in a single response | | [`GET /wallets/{address}/transactions`](/docs/endpoints/data/onchain/wallets-address-transactions) | The raw transfer list for a wallet: individual transfers, not aggregates | | [`GET /wallets/{address}/counterparties`](/docs/endpoints/data/onchain/wallets-address-counterparties) | The addresses this wallet transacted with most, by combined transfer volume over a window | | [`GET /wallets/{address}/pnl`](/docs/endpoints/data/onchain/wallets-address-pnl) | A daily net-flow series for the wallet — net inflow USD, cumulative, gross volume, transaction count | | [`GET /wallets/{address}/balances`](/docs/endpoints/data/onchain/wallets-address-balances) | Current ERC-20 balances across the major EVM chains, with a USD value and price per holding | | [`GET /wallets/{address}/trace`](/docs/endpoints/data/onchain/wallets-address-trace) | Follow the money outward — top counterparties per hop, up to `depth` hops | | [`GET /wallets/{address}/ens`](/docs/endpoints/data/onchain/wallets-address-ens) | Reverse ENS lookup — turn an address into `vitalik.eth` | | [`GET /wallets/compare`](/docs/endpoints/data/onchain/wallets-compare) | Are these two wallets related? Shared counterparties and shared tokens within a window | | [`GET /wallets/discover`](/docs/endpoints/data/onchain/wallets-discover) | The leaderboard: most active wallets over a window, ranked by the metric you pick | ## Tokens [#tokens] | Endpoint | What it answers | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | [`GET /tokens/search`](/docs/endpoints/data/onchain/tokens-search) | Resolve a symbol, name or contract address to the tokens we know about | | [`GET /tokens/screener`](/docs/endpoints/data/onchain/tokens-screener) | Find tokens by activity within a window — volume, net inflow, holder count or smart-money inflow | | [`GET /tokens/{token_address}/transfers`](/docs/endpoints/data/onchain/tokens-token-address-transfers) | Who bought and who sold a token over a period — the top buyers or the top sellers | | [`GET /tokens/{token_address}/holders`](/docs/endpoints/data/onchain/tokens-token-address-holders) | The holder leaderboard for a token, ranked by cumulative net inflow USD | ## Labels [#labels] | Endpoint | What it answers | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | [`POST /labels/batch`](/docs/endpoints/data/onchain/labels-batch) | Enrich a list of addresses in one call — entity, wallet type, risk flag and 30-day activity | | [`GET /labels/{address}/provenance`](/docs/endpoints/data/onchain/labels-address-provenance) | The full label cascade for one address, with per-source attribution | # /labels/{address}/provenance (/docs/endpoints/data/onchain/labels-address-provenance) ``` GET https://data.vike.io/v1/onchain/labels/{address}/provenance ``` The full label cascade for one address, with per-source attribution: what Arkham, OLI, Blockscout, Dune, the MEW darklist, Etherscan and our own heuristic classifier each claimed, plus a consensus entity and type. Use it when you need to know *who said* an address is a given exchange, not merely that something did. This capture is a good illustration of the ⚠ above, because it is a **thin** result for an address nobody would call obscure: one of the most-transacted exchange hot wallets on Ethereum resolves to `source_count: 1`. Whether the other named sources genuinely do not carry it or were mid-rebuild at capture time cannot be told from the response — which is exactly the ambiguity the warning describes. Two structural notes: * **`sources[]` entries are not the same shape as `consensus`.** A source carries `{source, label, entity, category}` in the provider's own vocabulary — note `"entity": "Binance"` capitalised and `"category": "Hot Wallet"` — while `consensus` carries the normalised `{entity, wallet_type, cex_product, user_subtype, risk_flag, confidence}` with `"entity": "binance"` lower-cased and `"wallet_type": "cex"`. Join on the consensus fields; the source fields are free text. * **The captured `source` reads `provider_a`, not a vendor name.** The sources are anonymised on the wire, so you cannot use this route to tell which of the named providers made a claim — only how many distinct sources did, and what each said. `found: true` with `source_count: 1` is a successful lookup. An address genuinely unknown to the cascade is the case to test your handling against separately. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/labels/0x28C6c06298d514Db089934071355E5743bf21d60/provenance" ``` Wallet address, 0x-prefixed hex The complete response for the Binance 14 hot wallet, captured 2026-09-03. ```json title="Example response" { "address": "0x28C6c06298d514Db089934071355E5743bf21d60", "found": true, "consensus": { "entity": "binance", "wallet_type": "cex", "cex_product": "retail", "user_subtype": "active", "risk_flag": null, "confidence": "medium" }, "sources": [ { "source": "provider_a", "label": "Binance: Hot Wallet (0x28C)", "entity": "Binance", "category": "Hot Wallet" } ], "source_count": 1 } ``` ⚠ It degrades rather than erroring: when an upstream label dump is mid-rebuild the route returns **fewer rows** instead of a failure. A thin provenance list is therefore ambiguous between *few sources know this address* and *a source is rebuilding* — re-query before concluding an address is unlabelled. # /labels/batch (/docs/endpoints/data/onchain/labels-batch) ``` POST https://data.vike.io/v1/onchain/labels/batch ``` Enrich a list of addresses in one call: entity, wallet type, risk flag, 30-day net flow, 30-day volume and 30-day transaction count, one row per input address **in input order**. This is the route for a CSV of addresses rather than a single wallet. The rows come back **in input order** as documented, and each echoes its own `wallet` — so you can verify the alignment rather than trusting the ordering. The echoed address is lower-cased, while the first address was sent checksummed; compare case-insensitively. This is also the shape to check against the ⚠ above: a successful row carries `found: true` and no `error` key. A **rejected** batch returns HTTP 200 carrying `[{"error": "..."}]` — one element, one key — so the test is `"error" in rows[0]`, and it must be run before treating the array as data. Two field notes from the capture. `risk_flag` is the **empty string** rather than `null` on both clean rows, so a truthiness check is the right test and an `is None` check is not. And `wallet_type` is a classification of *behaviour*, not of ownership: `vitalik.eth` is typed `dex` here, which describes where its transfers land rather than what the address is — do not read `wallet_type` as "this address is a DEX". ```sh title="cURL" curl -X POST -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/labels/batch?addresses=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045,0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb" ``` Comma-separated addresses. Capped at 100 Accepted and **ignored** . Results are always Ethereum — see below Note the `-X POST` and the **absent `-d`** — as described earlier, this route is a POST with no body, so `curl` must be told the method explicitly. The complete response covers two addresses, captured 2026-09-03. ```json title="Example response" [ { "entity": "vitalik buterin", "wallet_type": "dex", "confidence": "medium", "risk_flag": "", "net_30d_usd": -70023.25164, "volume_30d_usd": 70046.74836, "tx_30d": 454, "tokens_30d_count": 157, "wallet": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", "found": true }, { "entity": "morpho", "wallet_type": "defi", "confidence": "medium", "risk_flag": "", "net_30d_usd": -357800362.784802, "volume_30d_usd": 2359366854024.6597, "tx_30d": 409814, "tokens_30d_count": 235, "wallet": "0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb", "found": true } ] ``` ⚠ **`chain` does nothing on this route.** The value is accepted and then never applied — labels and features are always read from Ethereum. So `chain=bsc` and `chain=base` return **Ethereum labels rather than an error**. The `wallet_batch_labels` MCP tool advertises the same three-value enum and behaves identically. This is the one dead `chain` in the namespace: `/wallets/{address}/counterparties`, `/tokens/{token_address}/holders` and `/tokens/screener` each genuinely honour it. This is the **only non-GET route in the `/v1` REST surface** — the published OpenAPI spec declares 61 GET operations and this one POST. Despite the method it takes **no request body**: `addresses` is a comma-separated query parameter, and the spec declares no `requestBody`. A JSON body is ignored. Because the addresses ride in the URL, a long batch makes a long URL. Split large batches across several calls rather than assuming the 100-address cap is reachable in one. ⚠ **A rejected batch arrives as HTTP 200, not 400.** Send more than 100 addresses and the response is a successful one carrying a one-element array whose single key is `error`, reading `batch capped at 100 addresses`. An empty `addresses` value behaves the same way, with `addresses (list) is required`. **Check for an `error` key in the first element before treating the array as data** — the status code will not tell you. # /tokens/screener (/docs/endpoints/data/onchain/tokens-screener) ``` GET https://data.vike.io/v1/onchain/tokens/screener ``` Find tokens by activity within a window — rank by volume, net inflow, holder count or smart-money inflow, with floors on volume and holder count and a smart-money-only toggle. ⚠ **`symbol` is empty and `name` is `null` on every captured row** — including USDC, WBTC and WETH, the three most identifiable ERC-20s on Ethereum. The screener identifies tokens **by address only**. Plan to resolve names yourself via [`/tokens/search`](/docs/endpoints/data/onchain/tokens-search) (which does carry `symbol`, `name` and a logo), and do not build a UI that assumes these two fields are populated. The addresses above are USDC, WBTC and WETH respectively. ⚠ **`net_flow_usd` is `0.0` on two of the three rows**, with `in_usd` exactly equal to `out_usd` to the cent. Where a token's flow nets to zero this precisely, the figures are almost certainly a double-counted transfer volume rather than a directional flow — every transfer landing on both sides. Sorting by `net_inflow` will rank such tokens at zero regardless of what actually moved. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/tokens/screener?limit=3&window=24h" ``` `ethereum` \| `bsc` \| `base` . Default `ethereum` `24h` \| `7d` \| `30d` \| `90d` . Default `24h` `volume` \| `net_inflow` \| `holders` \| `smart_inflow` . Default `volume` Drop tokens below this window volume Drop tokens below this holder count Boolean. Restrict to smart-money cohort flows Route default 50; **applied cap 100** The complete response for `limit=3`, captured 2026-09-03, is a bare array. ```json title="Example response" [ { "token_address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "", "name": null, "volume_usd": 137340073415.62, "net_flow_usd": -36603751.18, "in_usd": 68651734832.22, "out_usd": 68688338583.4, "unique_holders": 262663, "tx_count": 1706155 }, { "token_address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", "symbol": "", "name": null, "volume_usd": 127606890479.48, "net_flow_usd": 0.0, "in_usd": 63803445239.74, "out_usd": 63803445239.74, "unique_holders": 2785, "tx_count": 72340 }, { "token_address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "symbol": "", "name": null, "volume_usd": 107747906503.82, "net_flow_usd": 0.0, "in_usd": 53873953251.91, "out_usd": 53873953251.91, "unique_holders": 17781, "tx_count": 775235 } ] ``` # /tokens/search (/docs/endpoints/data/onchain/tokens-search) ``` GET https://data.vike.io/v1/onchain/tokens/search ``` Resolve a symbol, name or contract address to the tokens we know about. The entry point for the two address-keyed routes — [`/tokens/{token_address}/transfers`](/docs/endpoints/data/onchain/tokens-token-address-transfers) and [`/tokens/{token_address}/holders`](/docs/endpoints/data/onchain/tokens-token-address-holders). ⚠ **`token_address` comes back without the `0x` prefix**, while every route that consumes it — [`/tokens/{token_address}/holders`](/docs/endpoints/data/onchain/tokens-token-address-holders) and [`/tokens/{token_address}/transfers`](/docs/endpoints/data/onchain/tokens-token-address-transfers) — documents its path parameter as "0x-prefixed". Piping this field straight into those routes means prepending `0x` yourself. This is the first thing to check if a search result appears not to resolve. ⚠ **An exact symbol match returns one row, not a ranked list.** `q=USDC` with `limit=3` returned a single token, so a short result is not a truncation artifact and a `limit` above the match count costs nothing. It also means this route will not by itself warn you about symbol collisions across chains — pass `chain` explicitly when the symbol is ambiguous. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/tokens/search?q=USDC&limit=3" ``` Symbol, name, or address `ethereum` \| `bsc` \| `base` . Default `ethereum` Default 10, max 100 The complete response, captured 2026-09-03. `limit=3`, but only **one** token matched `USDC`. ```json title="Example response" [ { "symbol": "USDC", "name": "USD Coin", "token_address": "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "logo": "https://pub-468f7b552e104638ac0daeddde8a6aab.r2.dev/logos/coins/USDC.png" } ] ``` ⚠ The parameter is `q` here and `query` on the MCP tool — same search, different spelling. This is also the only endpoint in the namespace that MCP prices at **0 credits**; over REST it logs 1 like every other route. # /tokens/{token_address}/holders (/docs/endpoints/data/onchain/tokens-token-address-holders) ``` GET https://data.vike.io/v1/onchain/tokens/{token_address}/holders ``` The holder leaderboard for a token, ranked by cumulative net inflow USD. Returns wallet, net flow USD, volume USD, transaction count, first and last seen dates, and known labels. The capture makes the ⚠ above concrete. Every one of the top three USDC "holders" is an intermediary, not an owner: a Coinbase wallet, the Morpho lending protocol and an MEV bot. The second row moved **11.3 trillion USD** of volume to net 45.5 billion — a ratio that only makes sense for a contract money passes through. Read this route as *who moves the most of this token*, and use [`/wallets/{address}/balances`](/docs/endpoints/data/onchain/wallets-address-balances) when you mean who holds it. Note `entity` and `wallet_type` are populated on all three rows here (`coinbase`/`cex`, `morpho`/`defi`, `mev bot`/`mev`), which is what makes the list interpretable — an unlabelled address at the top of this ranking tells you very little on its own. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/tokens/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48/holders?limit=3" ``` Token contract address, 0x-prefixed `ethereum` \| `bsc` \| `base` . Default `ethereum` Route default 50; **applied cap 50** The USDC holder leaderboard, captured 2026-09-03 — the complete response for `limit=3`. ```json title="Example response" [ { "wallet": "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43", "net_flow_usd": 85269157826.77954, "volume_usd": 1920884159776.6255, "tx_count": 11431454, "first_seen": "2022-07-19", "last_active": "2026-09-03", "entity": "coinbase", "wallet_type": "cex" }, { "wallet": "0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb", "net_flow_usd": 45553365504.30071, "volume_usd": 11301542548648.928, "tx_count": 2150944, "first_seen": "2024-01-03", "last_active": "2026-09-03", "entity": "morpho", "wallet_type": "defi" }, { "wallet": "0x06cff7088619c7178f5e14f0b119458d08d2f5ef", "net_flow_usd": 27999681851.978294, "volume_usd": 5320232364455.69, "tx_count": 641309, "first_seen": "2025-02-06", "last_active": "2026-09-03", "entity": "mev bot", "wallet_type": "mev" } ] ``` ⚠ Ranked by *net inflow accumulated over our recorded transfer history*, not by current on-chain balance. A wallet that accumulated and then exited still ranks by what it moved through. For the present holdings of one specific wallet, use [`/wallets/{address}/balances`](/docs/endpoints/data/onchain/wallets-address-balances). # /tokens/{token_address}/transfers (/docs/endpoints/data/onchain/tokens-token-address-transfers) ``` GET https://data.vike.io/v1/onchain/tokens/{token_address}/transfers ``` Who bought and who sold a token over a period — the top buyers or the top sellers with quantities and USD values. `tab` selects which side you get; it is one side per call, not both. ⚠ **Half these fields are presentation, not data.** `addr_short`, the six `*_fmt` strings, `net_usd_raw` and `net_bar_pct` are pre-rendered for a UI — `net_bar_pct: 7` is a bar width, and `net_usd_fmt: "$-0.0"` on the second row is a formatted string, not a number. A machine consumer wants `address`, `bought`, `sold`, `net_tokens`, `usd_spent`, `usd_received`, `net_usd` and `chains`, and should ignore the rest. ⚠ **`net_usd` and `net_usd_raw` disagree by a factor of 1e6, and they disagree in sign too.** Row 1 reports `net_usd: 2954179998100` against `net_usd_raw: 2954180.0` — the `_raw` field is the smaller of the two, despite its name, and `net_usd_fmt` renders as `"$2.95M"`, agreeing with `net_usd_raw`. So the field the formatter trusts is `net_usd_raw`; `net_usd` carries the un-scaled figure. On row 1 the sign also flips: `net_tokens` is `-2954179998100` while `net_usd` is `+2954179998100`. ⚠ **`bought` and `usd_spent` are byte-identical on every captured row**, as are `sold` and `usd_received` — a token quantity and a USD amount reported as the same number. For a stablecoin those are legitimately close, but they are *exactly* equal here, so do not assume `usd_spent` has been priced independently of the token count. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/tokens/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48/transfers?period=24h&tab=sold" ``` Token contract address, 0x-prefixed `1h` \| `24h` \| `7d` \| `30d` . Default `24h` `bought` \| `sold` . Default `sold` `ethereum` \| `bsc` \| `base` \| `all` . Default `ethereum` USDC sellers over 24h, captured 2026-09-03. The response is a bare array of **exactly 10 rows** — confirming the fixed limit above. The first two rows are shown verbatim; the other eight have the same 18 fields. ```json title="Example response" [ { "address": "0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb", "bought": 14742019759758039, "sold": 14744973939756139, "net_tokens": -2954179998100, "usd_spent": 14742019759758039, "usd_received": 14744973939756139, "net_usd": 2954179998100, "chains": [ "eth" ], "title": null, "addr_short": "0xbbbb...ffcb", "bought_fmt": "14.74B", "sold_fmt": "14.74B", "net_tokens_fmt": "2.95M", "usd_spent_fmt": "$14.74B", "usd_received_fmt": "$14.74B", "net_usd_raw": 2954180.0, "net_usd_fmt": "$2.95M", "net_bar_pct": 7 }, { "address": "0x06cff7088619c7178f5e14f0b119458d08d2f5ef", "bought": 6138936624396122, "sold": 6138936624396121, "net_tokens": 1, "usd_spent": 6138936624396122, "usd_received": 6138936624396121, "net_usd": -1, "chains": [ "eth" ], "title": null, "addr_short": "0x06cf...f5ef", "bought_fmt": "6.14B", "sold_fmt": "6.14B", "net_tokens_fmt": "0.0", "usd_spent_fmt": "$6.14B", "usd_received_fmt": "$6.14B", "net_usd_raw": -0.0, "net_usd_fmt": "$-0.0", "net_bar_pct": 0 } ] ``` ⚠ **The row count is fixed at 10 and there is no way to change it.** The route exposes no `limit` parameter, and the MCP tool ignores one if you send it. For a longer ranking, use [`/holders`](/docs/endpoints/data/onchain/tokens-token-address-holders). # /wallets/{address}/balances (/docs/endpoints/data/onchain/wallets-address-balances) ``` GET https://data.vike.io/v1/onchain/wallets/{address}/balances ``` Current token balances for one wallet, read from the chain when you ask. The candidate token set comes from our own transfer index; the amounts come from `balanceOf` on the node, so a balance is what the contract says it is at that block rather than a figure derived from history. `ethereum` and `bsc` are the chains this route can answer for, because they are the two the transfer index covers and the index is what supplies the candidate tokens. `chains_scanned` in the response names what was actually read. Ask for a chain outside that set and it comes back named in `chain_errors` — **not** silently scanned and empty. A wallet reported as holding nothing because nobody looked is the one answer this endpoint must never give. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/balances?min_usd=3000" ``` Wallet address, 0x-prefixed, exactly 42 characters Comma-separated. `ethereum` and `bsc` ; the aliases `eth` , `mainnet` , `bnb` and `binance` are accepted. Omit for both. Hide holdings worth less than this. Default `0` , which filters nothing. ⚠ Any floor above `0` also excludes holdings we cannot price, since an unpriced holding cannot be shown to clear it. A live response, captured 2026-09-04 for `vitalik.eth` at `min_usd=3000`. ```json title="Example response" { "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", "total_usd": 10532.82, "chains_scanned": ["ethereum"], "holdings": [ { "chain": "ethereum", "symbol": "ENS", "name": "Ethereum Name Service", "address": "0xc18360217d8f7ab5e7c516566761ea12ce7f9d72", "balance": 1144.0360760636715, "balance_raw": "1144036076063671431479", "usd_value": 6843.62, "usd_price": 5.982 }, { "chain": "ethereum", "symbol": "WETH", "name": "Wrapped Ether", "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "balance": 1.461898164019089, "balance_raw": "1461898164019088870", "usd_value": 3689.2, "usd_price": 2523.57 }, { "chain": "ethereum", "symbol": "WEI", "name": "Wei Name Service", "address": "0x0000000000696760e15f265e828db644a0c242eb", "balance": 1.0, "balance_raw": "1", "usd_value": null, "usd_price": null } ] } ``` ## `usd_value: null` is a holding, not an error [#usd_value-null-is-a-holding-not-an-error] A balance is measured; a price is looked up, and most tokens do not have one — **434 of the tokens traded on Ethereum carry a price feed**, out of the hundreds of thousands that exist. A holding we cannot price is still listed, with `usd_value` and `usd_price` both `null`, because dropping it would under-report the wallet. `total_usd` therefore sums only the priced holdings. Read it as a floor, never as net worth, and never infer "worthless" from a `null` — it means *unpriced here*. ⚠ **This is why `min_usd` above `0` drops unpriced holdings, and why it defaults to `1`.** Most tokens in a long-lived wallet are unpriced airdrops — `vitalik.eth` carries over seven thousand of them. A value floor that kept everything it could not price would return all of them and filter almost nothing. `min_usd=0` is the way to ask for the complete list, and it is honoured: an omitted floor and an explicit zero are different requests. `balance_raw` is the integer the contract returned, as a string. Use it when you need exactness: `balance` is that integer divided by the token's decimals and is a float, so it loses precision on large balances. ## The native coin [#the-native-coin] ETH on `ethereum` and BNB on `bsc` are included when non-zero, with `address: null` — a native coin has no contract. Neither is priced by this route today, so both carry `usd_value: null`. ## What this route cannot see [#what-this-route-cannot-see] A token the wallet holds but which never appeared in our transfer index is invisible here: the index supplies the candidate list, and `balanceOf` is only called for tokens on it. That fails toward **omission** — a missing row, never a wrong number. It is the narrower of the two available gaps, and deliberately chosen. Until 2026-09-04 this route was served by a third-party API; a period when that vendor was unavailable returned `200` with an empty `holdings` array and `total_usd: 0.0`, which reads exactly like an empty wallet. Both shapes are on the [changelog](/docs/changelog/data). ## Related [#related] * [`/wallets/{address}`](/docs/endpoints/data/onchain/wallets-address) — the wallet summary * [`/wallets/{address}/transactions`](/docs/endpoints/data/onchain/wallets-address-transactions) — the transfer history the candidate list is drawn from * [Rate limits](/docs/data/rate-limits) · [Authentication](/docs/data/authentication) # /wallets/{address}/counterparties (/docs/endpoints/data/onchain/wallets-address-counterparties) ``` GET https://data.vike.io/v1/onchain/wallets/{address}/counterparties ``` The addresses this wallet transacted with most, by combined transfer volume over a window. Returns counterparty address, total volume, amount sent and received, transaction count, first and last seen, and labels where the label cascade knows the address. All three rows, as returned for `limit=3`. ⚠ **`first_seen` is the first contact *inside the window*, not the first ever.** In the capture above every `first_seen` lands within half an hour of `2026-08-04T08:00`, which is exactly 30 days before the call — the `window=30d` boundary, not a fact about the relationship. Widen the window and these dates move. For a genuine lifetime first-seen, use [`/wallets/{address}`](/docs/endpoints/data/onchain/wallets-address), whose `all` block carries one. USD on [`/discover`](/docs/endpoints/data/onchain/wallets-discover) reaches you already scaled to plain dollars. On the evidence of these captures, **this route does not scale it** — the numbers arrive in **micro-USD**. The same wallet pair, the same 30-day window, measured on the same day by two routes: | Route | Reported volume | `tx_count` | | ------------------------------------------------------------------------- | ---------------------- | ---------- | | `/counterparties` (above) | `4.51759577437892E+16` | 6078 | | [`/trace`](/docs/endpoints/data/onchain/wallets-address-trace) edge, same pair | `45175937046.39` | 6074 | `4.5175957743789e16 / 1e6 = 45,175,957,743.79` — the two agree to within 20,697 USD, and `/trace` is the smaller of the pair, consistent with its default 10,000 USD `min_usd` floor and the four fewer transfers it counts. So the two are one quantity at two scales, and this route reports the micro-USD one. **Divide `total_volume_usd`, `sent_usd` and `received_usd` by 1,000,000 to get USD**, and note the values also serialize in scientific notation (`4.51E+16`) — a strict JSON-number parser is fine with that, but a naive integer parse is not. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0x28C6c06298d514Db089934071355E5743bf21d60/counterparties?limit=3&window=30d" ``` Wallet address `ethereum` \| `bsc` \| `base` . Default `ethereum` `24h` \| `7d` \| `30d` \| `90d` \| `180d` \| `all` . Default `30d` Route default 100; **applied cap 50** A live response for the Binance 14 hot wallet, captured 2026-09-03. The response is a bare JSON array, not an object with a wrapper key. ```json title="Example response" [ { "wallet": "0xeae7380dd4cef6fbd1144f49e4d1e6964258a4f4", "total_volume_usd": 4.51759577437892E+16, "sent_usd": 0.0, "received_usd": 4.51759577437892E+16, "tx_count": 6078, "first_seen": "2026-08-04T08:30:11", "last_seen": "2026-09-03T08:00:11", "entity": "wintermute", "wallet_type": "market_maker" }, { "wallet": "0x21a31ee1afc51d94c2efccaa2092ad1028285549", "total_volume_usd": 4.216311846904386E+16, "sent_usd": 4.216311846904386E+16, "received_usd": 0.0, "tx_count": 2517, "first_seen": "2026-08-04T09:03:23", "last_seen": "2026-09-03T08:02:47", "entity": "binance", "wallet_type": "cex" }, { "wallet": "0xdfd5293d8e347dfe59e90efd55b2956a1343963d", "total_volume_usd": 4.006098785039075E+16, "sent_usd": 4.006098785039075E+16, "received_usd": 0.0, "tx_count": 2539, "first_seen": "2026-08-04T08:23:23", "last_seen": "2026-09-03T08:02:47", "entity": "binance", "wallet_type": "cex" } ] ``` # /wallets/{address}/ens (/docs/endpoints/data/onchain/wallets-address-ens) ``` GET https://data.vike.io/v1/onchain/wallets/{address}/ens ``` Reverse ENS lookup — turn an address into `vitalik.eth`. Returns null where the address has no ENS name set. Already-resolved names are answered from our own store and only an unknown address falls back to an external lookup, so the common case is fast and the slow case is the one that reaches upstream. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/ens" ``` Wallet address, 0x-prefixed, exactly 42 characters ⚠ **No successful response could be captured for this route.** On 2026-09-03 this route's call — for `vitalik.eth`, the example in this page's own description, and the address most likely to be in any pre-resolved table — returned **HTTP 400**. A second address (a Binance hot wallet) returned the same error. What came back was a single `detail` string carrying the upstream failure verbatim. ```json title="Example response" { "detail": "local miss + external lookup failed: Moralis HTTP 401: {\"message\":\"Your Moralis Free usage is paused. Upgrade to a paid plan to resume usage. Your existing setup remains in place - no migration or reconfiguration required: https://admin.moralis.com/settin" } ``` This is the documented fallback path failing at its second step, and the message says so precisely: the local table **missed**, the external lookup was attempted, and the provider answered 401 because its account is suspended. It is an upstream outage rather than a bad input, and it is the same outage that also affected [`/balances`](/docs/endpoints/data/onchain/wallets-address-balances); both now resolve against our own node. Three things to take from it: * **This route fails loudly where its two siblings fail quietly.** A local miss plus an external failure is a **400**, not a 200 with `null`. So `null` genuinely means *no ENS name is set*, and an error genuinely means *we could not find out* — a cleaner contract than `/balances` offers, and worth relying on. * **`vitalik.eth` missing from the local table** suggests the pre-resolved table is narrower than "the common case is local and fast" implies. Budget for the external path being taken. * ⚠ **The upstream vendor's message is passed through verbatim to the caller**, including the provider's name and a link to its admin console, and is clipped mid-word at `/settin`. Do not parse `detail` — its content is an upstream string that will change without notice. # /wallets/{address}/pnl (/docs/endpoints/data/onchain/wallets-address-pnl) ``` GET https://data.vike.io/v1/onchain/wallets/{address}/pnl ``` A daily net-flow series for the wallet — per-day net inflow USD, a cumulative figure, gross volume and transaction count. The `wallet_pnl_history` tool says so itself: *"this is net inflow USD, not true realized PnL (which would require WAC accounting per token). Useful as a directional indicator."* A wallet that buys and holds shows a large negative net flow while being up on the position. Read the series as the direction and size of flow, not as profit. The remaining 8 points run to `2026-09-01`, ending at `cumulative_net_flow_usd: 18.27` — which matches the `total_net_flow_usd` at the top, so the header figure is the last cumulative value rather than an independent sum. ⚠ **The series is sparse: one point per day *with activity*, not one point per day in the range.** `days=30` returned 11 points, and the dates jump — `08-08`, `08-10`, `08-12`, then a six-day gap between `08-14` and `08-20`. A chart or a diff that assumes 30 consecutive daily rows will misalign. Zero-fill the calendar yourself if you need a dense series. Note also that a day can carry `net_flow_usd: 0.0` with a non-zero `tx_count` (the captured `2026-08-14` has `tx_count: 2`), so a present-but-zero point is not the same as an absent one. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/pnl?days=30" ``` Wallet address `ethereum` \| `bsc` \| `base` . Default `ethereum` Days back. Default 30, clamped to the range 1–365 A live response for `vitalik.eth`, captured 2026-09-03. First three of **11** series points. ```json title="Example response" { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "chain": "ethereum", "days": 30, "total_net_flow_usd": 18.27, "series": [ { "date": "2026-08-08", "net_flow_usd": 0.02, "gross_volume_usd": 0.02, "tx_count": 1, "unique_tokens": 1, "cumulative_net_flow_usd": 0.02 }, { "date": "2026-08-10", "net_flow_usd": 0.19, "gross_volume_usd": 0.19, "tx_count": 1, "unique_tokens": 1, "cumulative_net_flow_usd": 0.21 }, { "date": "2026-08-12", "net_flow_usd": 0.01, "gross_volume_usd": 0.01, "tx_count": 1, "unique_tokens": 1, "cumulative_net_flow_usd": 0.22 } ] } ``` # /wallets/{address}/trace (/docs/endpoints/data/onchain/wallets-address-trace) ``` GET https://data.vike.io/v1/onchain/wallets/{address}/trace ``` Follow the money outward. Starting at `address`, expand at each hop to the top `width` counterparties by USD volume, up to `depth` hops. The response shape is `{root, depth, width, nodes, edges, stats}`, where nodes carry `{address, depth, label, wallet_type, total_volume_usd}` and edges carry `{from, to, volume_usd, tx_count}`. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0x28C6c06298d514Db089934071355E5743bf21d60/trace?depth=2&width=3&window=30d" ``` Root wallet to trace from `ethereum` \| `bsc` \| `base` . Default `ethereum` Hops to follow. Default 2, clamped to 1–5 Top N counterparties per hop. Default 5, clamped to 1–10 `24h` \| `7d` \| `30d` \| `90d` \| `all` . Default `30d` Minimum USD per transfer to follow. Default **10,000** A live response for the Binance 14 hot wallet, captured 2026-09-03. The full response carries **7 nodes and 12 edges**; the capture carries the root plus its three depth-1 nodes, the first three edges, and the closing `stats` block verbatim. ```json title="Example response" { "root": "0x28C6c06298d514Db089934071355E5743bf21d60", "depth": 2, "width": 3, "window": "30d", "min_usd": 10000.0, "nodes": [ { "address": "0x28c6c06298d514db089934071355e5743bf21d60", "depth": 0, "total_volume_usd": 127397751311.68404, "label": "binance", "wallet_type": "cex" }, { "address": "0xeae7380dd4cef6fbd1144f49e4d1e6964258a4f4", "depth": 1, "total_volume_usd": 45175937046.3857, "label": "wintermute", "wallet_type": "market_maker" }, { "address": "0x21a31ee1afc51d94c2efccaa2092ad1028285549", "depth": 1, "total_volume_usd": 42162018604.0239, "label": "binance", "wallet_type": "cex" }, { "address": "0xdfd5293d8e347dfe59e90efd55b2956a1343963d", "depth": 1, "total_volume_usd": 40059795661.27443, "label": "binance", "wallet_type": "cex" } ], "edges": [ { "from": "0x28c6c06298d514db089934071355e5743bf21d60", "to": "0xeae7380dd4cef6fbd1144f49e4d1e6964258a4f4", "volume_usd": 45175937046.39, "out_usd": 0.0, "in_usd": 45175937046.39, "tx_count": 6074 }, { "from": "0x28c6c06298d514db089934071355e5743bf21d60", "to": "0x21a31ee1afc51d94c2efccaa2092ad1028285549", "volume_usd": 42162018604.02, "out_usd": 42162018604.02, "in_usd": 0.0, "tx_count": 2268 }, { "from": "0x28c6c06298d514db089934071355e5743bf21d60", "to": "0xdfd5293d8e347dfe59e90efd55b2956a1343963d", "volume_usd": 40059795661.27, "out_usd": 40059795661.27, "in_usd": 0.0, "tx_count": 2263 } ], "stats": { "node_count": 7, "edge_count": 12, "max_depth_reached": 2 } } ``` Three things that `{root, depth, width, nodes, edges, stats}` summary does not tell you, all visible above: * **The echoed request is part of the response.** `depth`, `width`, `window` and the resolved `min_usd` come back at the top level, so a response records the floor it was taken under — useful given that omitting `min_usd` silently selects 10,000 rather than 0. * **Edges carry a direction breakdown** that summary omits: `out_usd` and `in_usd` alongside `volume_usd`. The first captured edge is `in_usd` only — money flowing *toward* the root — so "trace" is not outbound-only despite the name. * **`depth=2, width=3` yielded 7 nodes, not the 1 + 3 + 9 = 13 the arithmetic suggests.** Expansion is deduplicated and bounded by what actually clears `min_usd`; read `stats.node_count` rather than predicting it. `max_depth_reached` tells you whether the graph ran out before `depth` did. ⚠ **Unrecognised values fall back silently rather than erroring.** A misspelled `chain` reads Ethereum and a misspelled `window` reads 30 days — both with a **200** and no warning, so a typo returns plausible data for the wrong question. Spell these from the enums above. Cost is the product of `depth` and `width`, so this is the heaviest route in the namespace; the MCP tool prices it at 4 credits against a namespace median of 2. # /wallets/{address}/transactions (/docs/endpoints/data/onchain/wallets-address-transactions) ``` GET https://data.vike.io/v1/onchain/wallets/{address}/transactions ``` The raw transfer list for a wallet: individual transfers, not aggregates. The `wallet_transactions` tool description names the fields it returns — time, tx\_hash, from, to, token\_address, value\_usd, direction, block. This is the forensic route; [`/counterparties`](/docs/endpoints/data/onchain/wallets-address-counterparties) is the summarised view of the same underlying transfers. ⚠ **Three field names differ from the tool description quoted above.** The wire keys measured on this route are **`usd`** (not `value_usd`), **`from_address`** and **`to_address`** (not `from` / `to`). Bind your parser to the wire names, not to the tool's prose — `from` could not be the wire name in any case without colliding with a reserved word in several client languages. ⚠ **`usd` is `0.0` on all three captured rows**, including a USDT transfer from an exchange hot wallet, so a zero here means *not priced* rather than *worthless*. The consequence is on `min_usd`: if that floor filters on this same field, any `min_usd` above zero would have dropped all three of these rows. Verify against your own data before relying on `min_usd` to mean "large transfers only" — on this evidence it may equally mean "rows that got a price at all". ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0x28C6c06298d514Db089934071355E5743bf21d60/transactions?limit=3&days=30" ``` Wallet address `ethereum` \| `bsc` \| `base` . Default `ethereum` `in` \| `out` . Omit for both directions Days back. Default 30, clamped to 365 Drop transfers below this USD value. Default 0 Restrict to a single token contract Route default 100; **applied cap 500** A live response for the Binance 14 hot wallet, captured 2026-09-03 — a bare array, three rows for `limit=3`. ```json title="Example response" [ { "time": "2026-09-03T08:09:47", "tx_hash": "0x4a8eee513cd07ca136e89877e14f1cebb4dd87d2e034c1f27158d461fdafbd24", "usd": 0.0, "block": 25895491, "from_address": "0x28c6c06298d514db089934071355e5743bf21d60", "to_address": "0xf857b69686cca09049d7732e9d21a5fe7752f919", "token_address": "0x0000000000000000000000000000000000000000", "direction": "out" }, { "time": "2026-09-03T08:09:47", "tx_hash": "0x56e3b5cd7cb4ab29ac062eaa8f027951619ee4f47c3875c56da3f1689f486ae1", "usd": 0.0, "block": 25895491, "from_address": "0x28c6c06298d514db089934071355e5743bf21d60", "to_address": "0xc5fe50deb784983b0967b22fafbf239841518829", "token_address": "0x888888848b652b3e3a0f34c96e00eec0f3a23f72", "direction": "out" }, { "time": "2026-09-03T08:09:47", "tx_hash": "0xc3df2299b2f1cd0b15eb7d6f3ea6b064e1f504c5a2cf1d52e03d62e9eb3764f4", "usd": 0.0, "block": 25895491, "from_address": "0x28c6c06298d514db089934071355e5743bf21d60", "to_address": "0x120ecaf0cb973718cd6dee36ba0b8c42fbde7ca1", "token_address": "0xdac17f958d2ee523a2206206994597c13d831ec7", "direction": "out" } ] ``` # /wallets/{address} (/docs/endpoints/data/onchain/wallets-address) ``` GET https://data.vike.io/v1/onchain/wallets/{address} ``` Aggregate metrics for one wallet — volume, inflow, outflow, net flow, transaction count and tokens traded, across the 24h / 7d / 30d / 90d / all windows in a single response. The cheapest way to size up an address before spending calls on its detail routes. A live response for `vitalik.eth`, captured 2026-09-03. All five windows come back in one object; `24h` is present and zeroed rather than omitted, and `first_seen` / `last_active` / `tokens_traded` repeat the all-time figure inside every window rather than being recomputed per window — read those three from `all` and ignore the copies: That is the whole response, not an excerpt. Note the shape of a long-lived but currently quiet address: 932,913 lifetime transactions against 18 in the last 30 days. Sizing a wallet up on `all` alone will mislead you about whether it is active *now* — which is what the window breakdown is for. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" ``` Wallet address, 0x-prefixed hex ```json title="Example response" { "all": { "total_volume": 172096152.066662, "total_inflow": 59024302.03353, "total_outflow": 113071850.033132, "net_flow": -54047547.999602, "tx_count": 932913, "tokens_traded": 12365, "first_seen": "2016-08-05", "last_active": "2026-09-01" }, "90d": { "total_volume": 273.294302, "total_inflow": 87.693162, "total_outflow": 185.60114, "net_flow": -97.907978, "tx_count": 91, "tokens_traded": 12365, "first_seen": "2016-08-05", "last_active": "2026-09-01" }, "30d": { "total_volume": 18.272136, "total_inflow": 18.272136, "total_outflow": 0.0, "net_flow": 18.272136, "tx_count": 18, "tokens_traded": 12365, "first_seen": "2016-08-05", "last_active": "2026-09-01" }, "7d": { "total_volume": 0.1, "total_inflow": 0.1, "total_outflow": 0.0, "net_flow": 0.1, "tx_count": 1, "tokens_traded": 12365, "first_seen": "2016-08-05", "last_active": "2026-09-01" }, "24h": { "total_volume": 0.0, "total_inflow": 0.0, "total_outflow": 0.0, "net_flow": 0.0, "tx_count": 0, "tokens_traded": 12365, "first_seen": "2016-08-05", "last_active": "2026-09-01" } } ``` No query parameters — the windows are all returned together rather than selected. # /wallets/compare (/docs/endpoints/data/onchain/wallets-compare) ``` GET https://data.vike.io/v1/onchain/wallets/compare ``` Are these two wallets related? Returns the counterparties both wallets transacted with and the tokens both touched, within a window. Declared before `/wallets/{address}` so that `compare` is not read as an address. ⚠ **`summary` counts what was returned, not what exists.** Both counts read `3` against a `limit=3` call — they are the length of the returned arrays, already truncated by `limit`. A `shared_token_count` of 3 therefore does not mean these two wallets share exactly three tokens; it means you asked for three. Raise `limit` (applied cap 50) until the count stops rising if you need the true overlap. ⚠ **Choose both addresses for activity, or the answer is empty and says nothing.** Run against a quiet address and `shared_counterparties` comes back `[]` — a truthful result that reads identically to "unrelated". The captured pair are both high-traffic wallets, which is why the overlap is populated. `a_volume_usd` of `101.77817` against `b_volume_usd` of `782907715.73` on the third row is the useful shape here: a shared counterparty can be central to one wallet and incidental to the other, so weigh the two columns rather than treating any shared row as evidence of a link. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/compare?address_a=0x28C6c06298d514Db089934071355E5743bf21d60&address_b=0xeae7380dd4cef6fbd1144f49e4d1e6964258a4f4&limit=3&window=30d" ``` First wallet Second wallet `ethereum` \| `bsc` \| `base` . Default `ethereum` `24h` \| `7d` \| `30d` \| `90d` \| `180d` \| `all` . Default `30d` Max shared items per category. Route default 100; **applied cap 50** Binance 14 against a Wintermute market-making wallet, captured 2026-09-03. The full response is shown except that `shared_tokens` is trimmed from three entries to one. ```json title="Example response" { "address_a": "0x28C6c06298d514Db089934071355E5743bf21d60", "address_b": "0xeae7380dd4cef6fbd1144f49e4d1e6964258a4f4", "window": "30d", "chain": "ethereum", "shared_counterparties": [ { "wallet": "0x51c72848c68a965f66fa7a88855f9f7784502a7f", "a_volume_usd": 3124737069.567188, "b_volume_usd": 27413487685.73743, "a_tx_count": 361, "b_tx_count": 1434, "entity": "wintermute", "wallet_type": "market_maker" }, { "wallet": "0xf8191d98ae98d2f7abdfb63a9b0b812b93c873aa", "a_volume_usd": 8952220569.76122, "b_volume_usd": 6464235700.270625, "a_tx_count": 518, "b_tx_count": 1472, "entity": "wintermute", "wallet_type": "market_maker" }, { "wallet": "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43", "a_volume_usd": 101.77817, "b_volume_usd": 782907715.73214, "a_tx_count": 3, "b_tx_count": 752, "entity": "coinbase", "wallet_type": "cex" } ], "shared_tokens": [ { "token_address": "0x5a98fcbea516cf06857215779fd812ca3bef1b32", "a_volume_usd": 261683883072.97073, "b_volume_usd": 80125236671.59358, "a_tx_count": 2266, "b_tx_count": 378 } ], "summary": { "shared_counterparty_count": 3, "shared_token_count": 3 } } ``` Both addresses are required, and both are query parameters rather than path segments. Omitting either is a **422** carrying a validation list (`{"detail":[{"type":"missing","loc":["query","address_b"],…}]}`, measured live). Sending both but leaving one *blank* is a different failure: a **400** reading `address_a and address_b are required`. # /wallets/discover (/docs/endpoints/data/onchain/wallets-discover) ``` GET https://data.vike.io/v1/onchain/wallets/discover ``` The leaderboard: most active wallets over a window, ranked by the metric you pick. Use it to find addresses worth investigating, then move to the [per-wallet routes](/docs/endpoints/data/onchain#wallets). `total` is the size of the matching population — 8,330,571 wallets — not the number of rows returned. With `size` capped at 20 this route is a *top-of-leaderboard* view; there is no way to page deep into that population from here. ⚠ **`realized_pnl` is not on the same scale as the USD columns beside it.** Row 1 reports `volume` of 2.55e12 alongside `realized_pnl` of 1.13e16 — a realized profit four thousand times the wallet's own gross volume, which cannot be a USD reading. Divide by 1e6 and it lands near 1.1e10 — still large, but now below the wallet's own volume rather than four thousand times above it, which is at least a shape a profit figure could take. Treat `realized_pnl` as a **ranking key rather than a dollar figure** unless you have confirmed its scale, and note `pnl_available: 1` only asserts that a value was computed, not that it is denominated as you expect. ⚠ **`first_seen` here is not a lifetime first-seen either.** All three rows report June 2026 against a `window=30d` call — earlier than the window, so it is not simply clamped to it as on [`/counterparties`](/docs/endpoints/data/onchain/wallets-address-counterparties), but it is also far too recent for wallets of this size to be genuinely new. Read it as "first seen in the dataset behind this route". [`/wallets/{address}`](/docs/endpoints/data/onchain/wallets-address) reported `2016-08-05` for a wallet on the same day, so the two routes do not agree on what the field means. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/onchain/wallets/discover?size=3&window=30d" ``` `24h` \| `7d` \| `30d` \| `90d` \| `all` . Default `30d` `volume` \| `inflow` \| `outflow` \| `net_flow` \| `tx_count` \| `pnl` . Default `volume` `asc` \| `desc` . Default `desc` `ethereum` \| `bsc` . Default `ethereum` Results per page. Default 10, max 20 — one of the two routes in this namespace whose accepted and applied caps agree, alongside [`/tokens/search`](/docs/endpoints/data/onchain/tokens-search) The full response for `size=3`, captured 2026-09-03 — all three wallets plus the `total`. ```json title="Example response" { "wallets": [ { "address": "0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb", "volume": 2552546474138.343, "inflow": 1276307059282.2302, "outflow": 1276239414856.1125, "net_flow": 67644426.117664, "tx_count": 367347, "tokens_traded": 33, "first_seen": "2026-06-04", "last_active": "2026-09-02", "realized_pnl": 11268691976748202, "tokens_profitable": 18, "pnl_available": 1 }, { "address": "0x06cff7088619c7178f5e14f0b119458d08d2f5ef", "volume": 695617826252.817, "inflow": 347809463499.2067, "outflow": 347808362753.6103, "net_flow": 1100745.596406, "tx_count": 174226, "tokens_traded": 245, "first_seen": "2026-06-04", "last_active": "2026-09-02", "realized_pnl": 2399649170518083.5, "tokens_profitable": 76, "pnl_available": 1 }, { "address": "0xd226997439ecfbeff8e110c8c78c8a7eefd19f89", "volume": 424200696674.0756, "inflow": 212100372292.9297, "outflow": 212100324381.146, "net_flow": 47911.7837, "tx_count": 70510, "tokens_traded": 155, "first_seen": "2026-06-05", "last_active": "2026-09-03", "realized_pnl": 3120711434553757.0, "tokens_profitable": 33, "pnl_available": 1 } ], "total": 8330571 } ``` Note the parameter is `size`, not `limit`, and the chain enum is narrower here than elsewhere in the namespace: `ethereum` and `bsc` only, no `base`. Each row carries `address`, `volume`, `inflow`, `outflow`, `net_flow`, `tx_count`, `tokens_traded`, `first_seen`, `last_active`, `realized_pnl`, `tokens_profitable` and `pnl_available`, alongside a `total`. ⚠ Every USD-denominated column reaches you as **plain USD**, already scaled — do not rescale them. # /archive/datasets (/docs/endpoints/data/polymarket/archive-datasets) ``` GET /v1/polymarket/archive/datasets ``` A JSON index of every downloadable archive partition, across **both** Hive layouts: the flat `venue=polymarket/date=.../` layout (every recorded market that day — the only discoverable path for anything outside a crypto up/down family, such as sports or politics) and the family-partitioned `venue=polymarket/asset=/tenor=/date=.../` layout (one crypto up/down family's own much smaller file). Rows arrive newest date first. The unfiltered call — no parameters at all, which is the one worth seeing first because it shows what the defaults do: **Truncated for the page: `datasets` held 200 rows; the first two are shown.** The envelope is complete and is the part that matters — sending no `limit` returned `"limit": 200`, the default echoed back, and `"total": 2100` against 200 rows delivered. **That first page is under a tenth of the archive**, which is the paging point made with real numbers: page until `offset + len(datasets) >= total`. Rows arrive newest date first, and the two shown are the same date's two streams. Note the first one's size — **4,799,571,940 bytes and 306,994,531 rows for a single day of `book_events`.** This is why `bytes` and `rows` are in the index at all: a day of book events is a 4.8 GB download, and [the archive's download guidance](/docs/data/archive/downloading) matters before you fetch one. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/archive/datasets" ``` Matches **family-layout rows only** , e.g. `btc` . Supplying it excludes every flat row. Matches **family-layout rows only** , e.g. `5m` . One of `book_events` , `trades` , `l1_quotes` . `YYYY-MM-DD` , inclusive lower date bound. `YYYY-MM-DD` , inclusive upper date bound. Max rows. Default `200` , hard cap `1000` . Skip this many matching rows. Default `0` . ```json title="Example response" { "datasets": [ { "bytes": 4799571940, "date": "2026-09-02", "layout": "flat", "rows": 306994531, "stream": "book_events", "url": "https://data.vike.io/archive/venue=polymarket/date=2026-09-02/book_events.parquet", "venue": "polymarket" }, { "bytes": 12314277, "date": "2026-09-02", "layout": "flat", "rows": 1006555, "stream": "trades", "url": "https://data.vike.io/archive/venue=polymarket/date=2026-09-02/trades.parquet", "venue": "polymarket" } ], "limit": 200, "offset": 0, "total": 2100 } ``` Response — an object carrying the matching rows under `datasets`, each row with the absolute download URL alongside the known size and row count, so you can budget a download before starting it. This is `?limit=1&stream=trades`, fetched from the running service: ```json title="Example response — limit=1, stream=trades" { "datasets": [ { "bytes": 12314277, "date": "2026-09-02", "layout": "flat", "rows": 1006555, "stream": "trades", "url": "https://data.vike.io/archive/venue=polymarket/date=2026-09-02/trades.parquet", "venue": "polymarket" } ], "limit": 1, "offset": 0, "total": 700 } ``` `limit` and `offset` echo back the values actually in force — `limit` reports the default `200` when you send none. **`total` is the count of rows matching your filters before `limit` and `offset` are applied**, and it is the only field that tells you when to stop: page until `offset + len(datasets) >= total`. It follows the filters rather than the page — measured in the same minute, an unfiltered call reported `2100`, `?stream=trades` `700`, and `?asset=btc` `270`. Measured against the live service: **`date=`, `layout=` and `venue=` are not parameters here.** A request carrying them returns the *unfiltered* result with status `200` and no warning — `?date=2026-09-01` came back with all 40 recorded dates, and `?venue=bogus` came back with a full page of Polymarket rows. Use **`from` and `to`** for dates, and note they take **`YYYY-MM-DD`**, not the epoch milliseconds that `from`/`to` mean on [`/ohlcv`](/docs/endpoints/data/polymarket/ohlcv). Layout is not selected directly: it follows from `asset`/`tenor`, which restrict the result to family rows. Because the default page is 200 rows out of considerably more, an unfiltered first page is not the whole archive. Page with `limit` and `offset`, or filter first. # /archive/url (/docs/endpoints/data/polymarket/archive-url) ``` GET /v1/polymarket/archive/url?date={YYYY-MM-DD}&stream={stream} ``` Resolves **one** partition to its absolute download URL plus that file's known byte and row counts. Use it when you already know the partition you want and do not need to enumerate; use [`/archive/datasets`](/docs/endpoints/data/polymarket/archive-datasets) when you are discovering. The **flat** layout — a date and a stream, no `asset`/`tenor`: `layout` echoes back which of the two shapes you resolved, and `bytes`/`rows` are that file's real size — 12.3 MB and 1,006,555 rows here — so you can budget the download before starting it. Note that `asset` and `tenor` are simply absent from a flat response rather than present and null. The **family** layout answers with the same fields plus those two. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/archive/url?date=2026-09-02&stream=trades" ``` `YYYY-MM-DD` . Omitting it returns `400 {"error":"get_archive_url requires date"}` . One of `book_events` , `trades` , `l1_quotes` . Supply **both** `asset` and `tenor` to resolve the family layout, or **neither** to resolve the flat layout. As above. `true` returns `302` with the file in `Location` instead of the JSON body. ```json title="Example response — date layout" { "bytes": 12314277, "date": "2026-09-02", "layout": "flat", "rows": 1006555, "stream": "trades", "url": "https://data.vike.io/archive/venue=polymarket/date=2026-09-02/trades.parquet", "venue": "polymarket" } ``` ```json title="Example response — asset/tenor layout" { "asset": "btc", "bytes": 5448402, "date": "2026-09-01", "layout": "family", "rows": 490669, "stream": "trades", "tenor": "5m", "url": "https://data.vike.io/archive/venue=polymarket/asset=btc/tenor=5m/date=2026-09-01/trades.parquet", "venue": "polymarket" } ``` **`asset` and `tenor` are all-or-nothing.** Supplying only one is refused rather than guessed: `400 {"error":"get_archive_url requires BOTH asset and tenor (family layout) or NEITHER (flat layout) — supplying only one is ambiguous"}`. **A partition that does not exist is a `400`, not a `404`** — `{"error":"no flat dataset found for date=1999-01-01 stream=trades"}`. This is a real answer, not necessarily a mistake: a family with zero rows in a stream on a given day has **no file** that day, which the archive documents as expected behaviour. # /events (/docs/endpoints/data/polymarket/events) ``` GET /v1/polymarket/events?token_id={token_id}&from_ms={from}&to_ms={to} ``` The raw per-market event tape: book snapshots and deltas, trade prints, tick-size changes and stream-health status markers, ordered by `(local_ts, seq, token_id)`. This is the finest grain the API offers, for one token or a family of up to 100 over a bounded window. **Truncated for the page: `limit=3` returned three events; the first two are shown, and `next_cursor` above is the real one from that three-event page** (it names the third event's `local_ts:seq:token_id`). `has_more` is `true` — with a `limit` this small it is true almost immediately, which is the point: it reflects the *page*, not the window. The window was chosen from [`/market_stats`](/docs/endpoints/data/polymarket/market-stats), which is the intended order of operations: `to_ms` here is that token's `last_ms` exactly, and `from_ms` is roughly an hour earlier. Asking for a window at the current wall clock would have returned an empty page for this token. ⚠ **`bids` and `asks` are JSON-encoded *strings*, not arrays.** `"bids": "[[0.782,0.0]]"` must be parsed a second time to become `[[0.782, 0.0]]`, and an event that carries no book side sends the empty string `""` rather than `null` or `[]`. ⚠ **Unpopulated numeric fields are `0`, not `null`.** These `price_change` rows carry `"best_ask": 0` and `"best_bid": 0` — that is "this event type does not populate the field", not "the best bid is zero". `status` is `""` for the same reason. Read fields according to `event_type`; do not treat a zero here as a price. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/events?token_id=95532524407959903145510583684979049735028502452973932716346555811355282494630&from_ms=1787180000000&to_ms=1787183633324&limit=3" ``` A single token — sugar for a one-element `token_ids` . Comma-separated token ids, **max 100 per request** . Wins over `token_id` if both are given. Window start, epoch ms, **inclusive** . Window end, epoch ms, **exclusive** . Must be greater than `from_ms` . Filter to one of `book` , `price_change` , `trade` , `tick_size_change` , `status` . Max rows per page. Default `5000` , hard cap `50000` . Resume strictly after this `local_ts:seq:token_id` triple, taken from a previous page's `next_cursor` . ```json title="Example response" { "events": [ { "asks": "", "best_ask": 0, "best_bid": 0, "bids": "[[0.782,0.0]]", "event_type": "price_change", "is_snapshot": 0, "local_ts": 1787180056379, "price": 0.782, "seq": 8404, "side": "buy", "size": 0, "status": "", "token_id": "95532524407959903145510583684979049735028502452973932716346555811355282494630", "ts": 1787180056368 }, { "asks": "", "best_ask": 0, "best_bid": 0, "bids": "[[0.782,6.25]]", "event_type": "price_change", "is_snapshot": 0, "local_ts": 1787180056894, "price": 0.782, "seq": 8405, "side": "buy", "size": 6.25, "status": "", "token_id": "95532524407959903145510583684979049735028502452973932716346555811355282494630", "ts": 1787180056883 } ], "has_more": true, "next_cursor": "1787180122865:8406:95532524407959903145510583684979049735028502452973932716346555811355282494630" } ``` **The window is capped at 24 hours.** A wider request is refused outright: `{"error":"get_events window too wide: 800000000000ms requested, max 86400000ms (24h)"}`. Page across days by moving the window, not by widening it. A request for more than 100 tokens is refused the same way, naming the count. **It is the only endpoint here that returns a paging envelope.** Every other endpoint in this namespace returns a bare array; this one returns an object — `{"events": [...], "next_cursor": "...", "has_more": true}`. Follow `has_more` and feed `next_cursor` back as `cursor`; do not assume a short page means the end of the window. If you want a whole day, or many days, of this tape, do not page it here — the same events are published as Parquet in the [bulk archive](/docs/data/archive), which is a single download instead of tens of thousands of pages. # Polymarket (/docs/endpoints/data/polymarket) Eleven endpoints under `https://data.vike.io/v1/polymarket/`. Nine are over the Polymarket CLOB as **recorded by Vike** — not a proxy of Polymarket's own API; two are trader routes over the on-chain trade tape. They answer on the same host, behind the same API key and the same hourly budget as every other namespace. These eleven answer a question at a time, in JSON. The **[Polymarket archive](/docs/endpoints/data/archive)** serves the same recording as Parquet files, a day at a time — 411 GB of book events, trades and L1 quotes, read with pyarrow or DuckDB rather than curl. ⚠ Reach for the archive whenever the answer is "every row in a window". A ranged file read does **not** spend the hourly request budget, while paging the same history through these endpoints does — and a Parquet reader issuing one Range request per column chunk is how a single 1.26 GB file once cost 20,237 units. `/v1/openapi.json` lists every endpoint here, but like the rest of `/v1` it declares no response bodies — which is why these pages exist and why every request line, field and status code on them was read off the live API rather than generated. Every endpoint here is `GET`. Any other method returns `405 {"error":"only GET is supported here"}`, and an unrouted path returns `404 {"error":"no such route"}`. ## One spelling [#one-spelling] `/v1/polymarket/...` is the only form. Seven older flat paths — `/v1/markets`, `/v1/markets/{condition_id}`, `/v1/market_stats`, `/v1/ohlcv`, `/v1/orderbook`, `/v1/settlements/{condition_id}` and `/v1/events` — answered until **2026-09-03** and now return `404`. They predated the namespace and were retired once the usage record showed no caller had ever used them. ⚠ If you have code on a flat path, add `/polymarket` after `/v1`; nothing else changes. The namespaced form matches `onchain` and `hyperliquid`, and cannot collide with a future route in those namespaces. **The archive endpoints have no flat alias.** `/v1/archive/datasets` and `/v1/archive/url` are **404** — those two are reachable **only** through `/v1/polymarket/archive/...`. **Bare `/v1/settlements` is routed but dead.** An authenticated caller pays the round trip and receives `404 {"error":"no such route"}`. The working form carries a condition id: `/v1/settlements/{condition_id}`. Do not call the bare path. ## Identifiers: `condition_id` and `token_id` [#identifiers-condition_id-and-token_id] Two different ids appear here and they are not interchangeable. * **`condition_id`** — a `0x`-prefixed hex string identifying a *market*. It is what the two path-parameter endpoints take. * **`token_id`** — a long decimal string identifying one *outcome token*. A binary market has two, returned in `token_ids`. It is what every query-parameter endpoint takes. Get both from [`/markets`](/docs/endpoints/data/polymarket/markets). The [older reference page](/docs/data/reference/polymarket) carries an annotated example row. One call gets you both, and it is the right first call in this namespace: ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/markets?query=bitcoin&limit=3" ``` Each row carries the market's `condition_id` and its two `token_ids`; every other endpoint here takes one or the other. [`/markets`](/docs/endpoints/data/polymarket/markets) shows the response. `/markets/{condition_id}` and `/settlements/{condition_id}` answer **`200` with an empty array `[]`** for an id they do not know — including a well-formed `token_id` passed where a `condition_id` belongs, which is a silent empty result rather than a rejection. Both were measured against the live service. Check for an empty array; do not rely on a 404. ## The eleven endpoints [#the-eleven-endpoints] | Endpoint | What it does | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | [`/markets`](/docs/endpoints/data/polymarket/markets) | The market directory — search and filter the recorded market universe by slug. | | [`/markets/{condition_id}`](/docs/endpoints/data/polymarket/markets-condition-id) | One market's detail — the same row shape as the directory, for a single market. | | [`/market_stats`](/docs/endpoints/data/polymarket/market-stats) | Aggregate recorded activity for one outcome token in a single row. | | [`/ohlcv`](/docs/endpoints/data/polymarket/ohlcv) | Candles for one outcome token, priced in probability, 0–1. | | [`/orderbook`](/docs/endpoints/data/polymarket/orderbook) | An order-book **snapshot** for one outcome token. | | [`/events`](/docs/endpoints/data/polymarket/events) | The raw per-market event tape: book snapshots and deltas, trade prints, tick-size changes and stream-health status markers. | | [`/settlements/{condition_id}`](/docs/endpoints/data/polymarket/settlements-condition-id) | The **on-chain settlement** of one market: which outcome won, what the payout vector was, and the block and timestamp at which it resolved. | | [`/wallets/{address}`](/docs/endpoints/data/polymarket/wallets-address) | One trader's profile — lifetime volume, realized P\&L and hit rate over a chosen window, markets, trades, positions and an equity curve. | | [`/smart_money`](/docs/endpoints/data/polymarket/smart-money) | Smart-money signals: markets the profitable cohort is buying (`flows`) or bought below the mid (`mispricings`). | | [`/archive/datasets`](/docs/endpoints/data/polymarket/archive-datasets) | A JSON index of every downloadable archive partition, across **both** Hive layouts. | | [`/archive/url`](/docs/endpoints/data/polymarket/archive-url) | Resolves **one** partition to its absolute download URL plus that file's known byte and row counts. | ## Bulk archive discovery [#bulk-archive-discovery] Two endpoints that index the Parquet archive rather than serving market data. They tell you what partitions exist and where to download them; the files themselves are served from `https://data.vike.io/archive/`, and [the archive pages](/docs/data/archive) cover the layouts, the schemas and — importantly — [how to download without exhausting your budget](/docs/data/archive/downloading). Both are namespaced-spelling only. There is no `/v1/archive/...` flat alias. ## Metering [#metering] Every call in this namespace spends one unit of the same hourly budget as MCP and the other `/v1` namespaces — see [Rate limits](/docs/data/rate-limits). Archive *downloads* from `https://data.vike.io/archive/` do not spend it, but the two discovery endpoints above are ordinary API calls and do. The mistake this namespace invites is worth repeating from that page: if you want book history rather than a snapshot, download whole archive partitions and read them locally. Pointing a Parquet client at an archive URL issues a request per column chunk, and one file can produce tens of thousands of them. ## REST and MCP: which routes share code [#rest-and-mcp-which-routes-share-code] Both surfaces are served from the same code where it matters, and they are **not** one-to-one: | REST endpoint | MCP tool (`https://vike.io/mcp`) | Same implementation? | | ------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------- | | `/ohlcv` | `polymarket_ohlcv` | Yes — the same query. | | `/wallets/{address}` | `polymarket_wallet` | Yes — the route runs the tool. | | `/smart_money` | `polymarket_smart_money` | Yes — the route runs the tool. | | `/markets` | `polymarket_markets` | **No.** REST searches the recorded universe by slug; the tool ranks the top markets by 24h volume. | | `/markets/{condition_id}` | `polymarket_market_detail` | **No.** Different row shapes. | | `/market_stats`, `/orderbook`, `/events`, `/settlements/{condition_id}`, `/archive/*` | — | REST only. | The second Polymarket MCP server that once answered at `data.vike.io/mcp` (nine tools, one per route) was retired on 2026-09-03 with zero authenticated calls in its history. There is one MCP server, and the [Polymarket tools](/docs/ai/data/polymarket) are on it; `data.vike.io/mcp` now reaches that same server. ## Related [#related] * [Polymarket REST (original reference)](/docs/data/reference/polymarket) — the earlier hand-written page, with an annotated `/markets` response. * [Bulk archive](/docs/data/archive) — full L2 book events, the taker tape and derived top-of-book as Parquet, and the download guidance the two discovery endpoints above lead to. * [Rate limits](/docs/data/rate-limits) · [Authentication](/docs/data/authentication) # /market_stats (/docs/endpoints/data/polymarket/market-stats) ``` GET /v1/polymarket/market_stats?token_id={token_id} ``` Aggregate recorded activity for one outcome token in a single row — total events, trade count, trade volume, first and last event timestamps, and how many book snapshots were captured. This is the cheap orientation call: it tells you whether a token has any recorded history, and over what span, before you request the tape itself. Read the two timestamps before anything else. Here they are 2026-07-25T03:51:50Z and 2026-08-19T23:53:53Z — so this token's recorded tape **ends well before "now"**, and a [`/events`](/docs/endpoints/data/polymarket/events) or [`/orderbook`](/docs/endpoints/data/polymarket/orderbook) request aimed at the current hour would return nothing for it while a request inside that window returns plenty. That is exactly what this call is for: `last_ms` is not a promise of freshness, it is the end of what was recorded. Note also how far apart the counts sit — 202,244 events and 10,674 snapshots against just 302 trades. Event volume is dominated by book activity, not by prints. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/market_stats?token_id=95532524407959903145510583684979049735028502452973932716346555811355282494630" ``` The outcome token. Omitting it returns `400 {"error":"market_stats requires token_id"}` . ```json title="Example response" [ { "first_ms": 1784951510849, "last_ms": 1787183633324, "snapshots": 10674, "total_events": 202244, "trade_volume": 39301.396175, "trades": 302 } ] ``` Returns a one-element array whose row carries `total_events`, `trades`, `trade_volume`, `first_ms`, `last_ms` and `snapshots`. The two timestamps are epoch milliseconds and bound the recorded window — they are the range [`/events`](/docs/endpoints/data/polymarket/events) can serve for that token. # /markets/{condition_id} (/docs/endpoints/data/polymarket/markets-condition-id) ``` GET /v1/polymarket/markets/{condition_id} ``` One market's detail — the same row shape as the directory, for a single market. Call it when you already hold a `condition_id` (from a Polymarket URL, a settlement row, or a stored id) and want its `token_ids`, tick size and lifecycle flags without searching. Note the shape: **a one-element array, not a bare object.** Even for a single market you index `[0]`. It is the same row the directory returns, which is why an unknown id is an empty array rather than a 404 — see the callout below. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/markets/0x7b9072e6a9cdcf022c4f098564e8ee612d544e179e4a33dcc2790fbaa778daa8" ``` The `0x` -prefixed market condition id. ```json title="Example response" [ { "active": 1, "closed": 0, "condition_id": "0x7b9072e6a9cdcf022c4f098564e8ee612d544e179e4a33dcc2790fbaa778daa8", "end_date": 1798779600000, "neg_risk": 1, "slug": "will-bitcoin-reach-70000-by-december-31-2026-from-june-8", "tick_size": 0.001, "token_ids": [ "95532524407959903145510583684979049735028502452973932716346555811355282494630", "107869950127184141743772429738701578723770582686430100389837827512148691029625" ] } ] ``` **The path segment is matched as a `condition_id` only.** The MCP twin `get_market` accepts either a condition id or any of the market's token ids; the REST path form does not. A `token_id` in this position returns `[]` with status `200` — measured. There is no query-parameter form: a `?token_id=` on this path is ignored. # /markets (/docs/endpoints/data/polymarket/markets) ``` GET /v1/polymarket/markets ``` The market directory — search and filter the recorded market universe by slug. Returns a bare JSON array ordered by `end_date` descending, each row carrying `condition_id`, `slug`, `token_ids`, `tick_size`, `neg_risk`, `active`, `closed` and `end_date`. This is the entry point: almost every other endpoint here needs an id that only this one hands out. All three rows are shown — `limit=3` bounded the response. The `token_ids` in the first row are what every query-parameter endpoint in this namespace takes; the `condition_id` is what the two path-parameter endpoints take. `end_date` is epoch milliseconds (`1798779600000` is 2027-01-01T05:00:00Z, i.e. the end of 2026 in US Eastern time). The third slug is not a transcription error: some slugs carry a long numeric tail, and a client that assumes a short human-readable slug will be surprised by one. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/markets?query=bitcoin&limit=3" ``` Case-insensitive substring match on the market slug. `true` \= only active, `false` \= only inactive, omit = all. Max rows. Default `50` , **hard cap 100** — a larger value is silently clamped, not rejected. ```json title="Example response" [ { "active": 1, "closed": 0, "condition_id": "0x7b9072e6a9cdcf022c4f098564e8ee612d544e179e4a33dcc2790fbaa778daa8", "end_date": 1798779600000, "neg_risk": 1, "slug": "will-bitcoin-reach-70000-by-december-31-2026-from-june-8", "tick_size": 0.001, "token_ids": [ "95532524407959903145510583684979049735028502452973932716346555811355282494630", "107869950127184141743772429738701578723770582686430100389837827512148691029625" ] }, { "active": 1, "closed": 0, "condition_id": "0x84fdc3f917e34acca874f93d978e84ed0c08969903ddf40a5d65274b3a5b4fdf", "end_date": 1798779600000, "neg_risk": 1, "slug": "will-bitcoin-reach-75000-by-december-31-2026-from-june-8", "tick_size": 0.001, "token_ids": [ "110439449768613802110790243898351375650535979513820669361590931221457616396762", "71940078455524699585822697954608343130226662273950516751010200431921477907081" ] }, { "active": 1, "closed": 0, "condition_id": "0x752fa61c93f16c4b15e85b0bd438d9c684176b0ea7b319a84c8aca34616e8a8c", "end_date": 1798779600000, "neg_risk": 1, "slug": "will-bitcoin-dip-to-55000-by-december-31-2026-527-627-868-745-188-361-314-673-612-946-821-624-855-557-684-381-884-939-182-939-516", "tick_size": 0.01, "token_ids": [ "115462087336983275104521488923966022755348986822352694745153736621832003960847", "48849252416222520684793262794712201381742900852794310345084361317209768152557" ] } ] ``` An annotated example response, and what `tick_size` and `neg_risk` mean, are on the [older reference page](/docs/data/reference/polymarket). # /ohlcv (/docs/endpoints/data/polymarket/ohlcv) ``` GET /v1/polymarket/ohlcv?token_id={token_id}&interval={interval} ``` Candles for one outcome token. Rows carry `ts`, `open`, `high`, `low`, `close`, `volume` and `trades`. All three bars are shown — `limit=3` bounded the response. The OHLC values are the probabilities the warning below describes: `0.73` is 73%, not $0.73. `volume` is in USDC and is *not* on that scale, so the two number families in one row mean different things. ⚠ **Bars are only emitted for intervals that traded, so `ts` is not a fixed-step series.** The first two bars above are `1784970000000` and `1785009600000` — 39,600,000 ms apart, eleven hours on a `1h` interval. A client that assumes consecutive rows are one interval apart, or that back-fills gaps by index, will misread this tape. Step by `ts`, never by position. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/ohlcv?token_id=95532524407959903145510583684979049735028502452973932716346555811355282494630&interval=1h&limit=3" ``` The outcome token. One of `1m` , `5m` , `1h` , `1d` . Anything else returns `400` naming the four accepted values. Window start, epoch ms, **inclusive** . Window end, epoch ms, **inclusive** . Max bars. Default `500` , hard cap `1000` . ```json title="Example response" [ { "close": 0.74, "high": 0.74, "low": 0.73, "open": 0.73, "trades": 3, "ts": 1784970000000, "volume": 279.729667 }, { "close": 0.73, "high": 0.73, "low": 0.73, "open": 0.73, "trades": 1, "ts": 1785009600000, "volume": 499.99 }, { "close": 0.73, "high": 0.73, "low": 0.73, "open": 0.73, "trades": 1, "ts": 1785013200000, "volume": 3.28767 } ] ``` **These candles are priced in probability, 0–1** — not in currency. They are a different endpoint and a different store from [`/v1/hyperliquid/ohlcv`](/docs/endpoints/data/hyperliquid/ohlcv), which serves Hyperliquid perp candles in currency. Same word, different data. Note also that this window is **inclusive at both ends**, while [`/events`](/docs/endpoints/data/polymarket/events) is half-open. The two endpoints do not share a convention. # /orderbook (/docs/endpoints/data/polymarket/orderbook) ``` GET /v1/polymarket/orderbook?token_id={token_id} ``` An order-book **snapshot** for one outcome token, taken from the nearest recorded book snapshot at or before `ts` — the latest one if `ts` is omitted. Rows carry `as_of_ts`, `side`, `level`, `price` and `size`, with `level` numbering outward from the touch, starting at 1 on each side. **Truncated for the page: 25 rows were returned, levels 1 through 25; the first three are shown.** Three things in this capture are worth more than the row shape: **`as_of_ts` was two weeks stale.** No `ts` was passed, so this is "the latest snapshot" — and the latest is `1787183491190`, 2026-08-19T23:51:31Z, for a call made on 2026-09-03. That is the paragraph above made concrete: the endpoint answered `200` with a perfectly well-formed book that was a fortnight old, and nothing but `as_of_ts` said so. **This response has no duplication at all** — 25 rows, 25 distinct `(side, level)` pairs. That is the factor-1 case the warning below describes. Do not read it as evidence the duplication is fixed; de-duplicate anyway, because the factor is per-token and moves day to day. ⚠ **Every one of the 25 rows is a `bid` — there is no `ask` row in the response.** Whether that is a genuinely one-sided book or `depth` bounding the *total* row count rather than each side is not settled by this single capture, and the two readings are not equivalent for a client computing a spread. Do not assume a response contains both sides: group by `side` and handle an absent one. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/orderbook?token_id=95532524407959903145510583684979049735028502452973932716346555811355282494630" ``` The outcome token. As-of epoch ms. The nearest snapshot at-or-before it is used; omit for the latest. Levels per side. Default `25` , hard cap `25` . ```json title="Example response" [ { "as_of_ts": 1787183491190, "level": 1, "price": 0.999, "side": "bid", "size": 303284.22 }, { "as_of_ts": 1787183491190, "level": 2, "price": 0.991, "side": "bid", "size": 10077 }, { "as_of_ts": 1787183491190, "level": 3, "price": 0.99, "side": "bid", "size": 20105 } ] ``` `as_of_ts` is the snapshot's own timestamp, not the `ts` you asked for — read it, because the nearest snapshot may be considerably older than your request. A book shallower than `depth` returns fewer levels; that is the real depth, not truncation. ⚠ **The same (`side`, `level`) can arrive several times as byte-identical rows — de-duplicate on (`side`, `level`) before summing `size`.** The repeats are not extra depth, and a client that sums the raw array overstates the book several-fold. **The multiplier is per-token and is not stable over time**, so do not code against a fixed factor: twelve tokens sampled in one minute on 2026-09-03 returned factors of 1 (no duplication at all), 2, 5, 9 and 11, and the token that returned 6 copies per level on 2026-09-02 returned 9 the next day. Every row in a response shares a single `as_of_ts`, so the repeats are one snapshot fanned out, not successive ones. Read the depth of a side as its `max(level)`, never as a row count. For book *history* rather than a snapshot, use the [bulk archive](/docs/data/archive) — it carries every book event, and it is far cheaper than replaying snapshots through this endpoint. # /settlements/{condition_id} (/docs/endpoints/data/polymarket/settlements-condition-id) ``` GET /v1/polymarket/settlements/{condition_id} ``` The **on-chain settlement** of one market: which outcome won, what the payout vector was, and the block and timestamp at which it resolved. Call it to determine, from chain truth, how a market actually ended. That is the call behind this page's response — a market that has already resolved on chain, so its payout vector is immutable and this example cannot go stale. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/settlements/0x69e0442186b71c5cac4fa6304311e39101ed048983a200fc8e376a9dadd7de13" ``` The `0x` -prefixed market condition id. As with [`/markets/{condition_id}`](/docs/endpoints/data/polymarket/markets-condition-id) , a `token_id` here returns `[]` . A real response, fetched from the running service. ```json title="Example response" [ { "condition_id": "0x69e0442186b71c5cac4fa6304311e39101ed048983a200fc8e376a9dadd7de13", "payouts": [0, 1], "resolved_block": 93138611, "resolved_ts_ms": 1788412950000, "slug": "will-donna-vekic-win-the-2026-womens-us-open", "token_ids": ["100208760979006815700821365554623989048585541964271047036444923380678560443951", "43437917871736289675135437047499696002273396903387531260510673476203211904981"], "winning_index": 1, "winning_token_id": "43437917871736289675135437047499696002273396903387531260510673476203211904981" } ] ``` `winning_index` indexes into `token_ids`, and `winning_token_id` is that entry resolved for you. `payouts` is the on-chain payout vector in the same order. This is the on-chain resolution, **not** the market's own lifecycle flags. This page's market was resolved on chain at block 93,138,611, yet `/markets/{condition_id}` still reports `"active": 1, "closed": 0` for it. The two sources answer different questions and can disagree for a market that has settled but not yet been reaped from the directory. **For "how did this end", this endpoint is the authority.** The [older reference page](/docs/data/reference/polymarket) carries a struck-through section stating that `/v1/settlements` is routed but not implemented and that "nothing behind it has ever existed", directing readers to `/v1/polymarket/events` instead. **That is wrong for the `/{condition_id}` form**, which is what this page documents — this page's response was measured live. The claim is correct only for the *bare* path `/v1/settlements`, which is a dead word in the legacy regex and does return `404 {"error":"no such route"}`. That page's correction is pending. # /smart_money (/docs/endpoints/data/polymarket/smart-money) ``` GET /v1/polymarket/smart_money ``` Where the wallets that have historically made money are putting it. The **cohort** is the set of wallets with at least $1,000 of realized P\&L, at least five resolved markets and at least $5,000 of volume; the route reports what that cohort is doing, in one of two modes. It is the same answer the [`polymarket_smart_money`](/docs/ai/data/polymarket) MCP tool gives — the route runs that tool. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/smart_money?mode=flows&hours=24&limit=2" ``` `flows` (default) — markets with the largest net cohort inflow in the window. `mispricings` — markets where the cohort bought meaningfully below the current mid. Any other value is a `400` . Lookback window in hours, an integer from 1 to 720. Default 24. Anything else is a `400` . Restrict to one market category (at most 64 characters). Default: all. An unknown category returns `[]` , not an error. Maximum rows, a positive integer. Default 10; values above 20 are capped at 20. A real response for `mode=flows`. ```json title="Example response (mode=flows)" [ { "condition_id": "0xe0b5bd8eef80fe8976e2c287e0b76a5cda138ddc437acf95f9ba91112dbc983d", "title": "Seahawks vs. Broncos", "slug": "nfl-sea-den-2026-10-16", "smart_wallets": 227, "buy_inflow": 3819464.49, "sell_outflow": 166081.01, "net_inflow": 3653383.48, "trades": 4576, "last_ts": "2026-10-16 03:49:49", "yes_mid": 0.015 } ] ``` And a real response for `mode=mispricings`. ```json title="Example response (mode=mispricings)" [ { "condition_id": "0x6a93a3190840da7ad3545168388c83c2244c4b5279bbe8ef537f1a091bcec7a0", "title": "Solana Up or Down - October 15, 10:50AM-10:55AM ET", "slug": "sol-updown-5m-1792075800", "smart_wallets": 49, "last_buy_ts": "2026-10-15 14:55:16", "total_buy_value": 1548.59, "side": "YES", "avg_buy_price": 0.2466, "current_mid": 0.985, "delta": 0.7384, "buy_value": 384.66 } ] ``` ## Reading it [#reading-it] * **`net_inflow` is dollars, not shares**: cohort buys minus cohort sells in the window. * **`yes_mid` can be `null`** — the market has no recorded mid yet. It is absent data, not zero. * **`mispricings` only lists clear cases.** A market appears when the cohort put at least $500 into it and the current mid is at least 0.03 away from their average entry in their favour. `delta` is `current_mid - avg_buy_price` for the side they bought; a large positive value means the market has already moved toward the cohort. It records where they were early and is not a forecast. * **`side` is the outcome position, not a literal Yes/No.** `YES` is the first outcome of the market and `NO` the second, so on an Over/Under or a two-team market read them as "outcome 1" and "outcome 2" and take the names from the market. * **A short-dated market can dominate.** Five-minute "up or down" markets resolve fast and show large deltas; filter with `category` when you want slower markets. ## Errors [#errors] | Status | When | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `mode`, `hours` or `limit` is outside the values above, `category` is too long, or an unknown query parameter is sent. The body is `{"error": "…"}`. | | `401` / `429` | No or bad credential, or the hourly budget is spent — see [Authentication](/docs/data/authentication) and [Rate limits](/docs/data/rate-limits). | | `503` | The store behind it is unavailable. The body carries no driver text. | # /wallets/{address} (/docs/endpoints/data/polymarket/wallets-address) ``` GET /v1/polymarket/wallets/{address} ``` One Polymarket trader, profiled from the **on-chain trade tape**: lifetime volume and trade counts, realized P\&L and hit rate over a chosen window, the markets the wallet traded, its recent trades, open positions and a daily equity curve. It is the same answer the [`polymarket_wallet`](/docs/ai/data/polymarket) MCP tool gives — the route runs that tool. ```sh title="cURL" curl -H "X-API-Key: $VIKE_API_KEY" \ "https://data.vike.io/v1/polymarket/wallets/0x204f72f35326db932158cba6adff0b9a1da95e14?window=30d" ``` The wallet's `0x` -prefixed proxy-wallet address (40 hex digits). Anything else is a `400` . `24h` , `7d` , `30d` or `all` . Default `all` . Any other value is a `400` . See *What the window moves* for which fields it changes. The response is large — up to 100 trades, 30 markets and a few hundred equity-curve points — so the real one is abridged in this example, each list cut to its first element. ```json title="Example response (abridged)" { "proxy_wallet": "0x204f72f35326db932158cba6adff0b9a1da95e14", "window": "30d", "trades_window": 0, "trades_all": 4331238, "volume_window_usd": 0.0, "volume_all_usd": 611448374.69, "avg_trade_usd": 141.17, "markets_count": 150749, "first_seen": "2026-01-01 00:00:37", "last_seen": "2026-08-26 09:17:49", "realized_pnl_usd": 379.10, "resolved_markets": 34, "hit_rate": 0.4105, "hit_rate_ci": 0.157, "open_positions": [], "open_exposure_usd": 0, "markets": [ { "condition_id": "0x392b…", "title": "…", "trades": 673, "volume_usd": 1575609.82, "net_shares": 1971349.29, "resolved": true, "winning_outcome": "Argentina", "realized_pnl": 384904.21 } ], "trades": [ "…" ], "equity_curve": [ "…" ], "category_breakdown": [ "…" ], "daily_activity": [ "…" ], "correlated": [ "…" ], "tags": [ "…" ], "is_active": false, "streak": { "n": 6, "kind": "win" } } ``` ## What the window moves [#what-the-window-moves] `window` scopes **some** fields and leaves the rest alone. | Field | Scoped by `window`? | Cut by | | -------------------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------- | | `trades_window`, `volume_window_usd` | Yes | trade time | | `realized_pnl_usd`, `resolved_markets`, `hit_rate`, `hit_rate_ci` | Yes | **resolution** time — only markets resolved inside the window count | | `trades_all`, `volume_all_usd`, `avg_trade_usd`, `markets_count`, `first_seen`, `last_seen`, share and dollar totals | No | lifetime | | `markets`, `trades`, `open_positions`, `equity_curve`, `category_breakdown`, `daily_activity`, `correlated` | No | full history | A wallet that traded long ago but resolved nothing inside the window reports `trades_window: 0` and `realized_pnl_usd: 0` — zeros that mean "nothing in this window", not "no activity". ## Reading it [#reading-it] * **`hit_rate` is volume-weighted, not a count of wins.** It is the share of the wallet's *resolved dollar volume* that sat in markets where it made money. With `resolved_markets: 34` an integer number of won markets will not reproduce it. `hit_rate_ci` is the Wilson confidence half-width on that rate; with few resolved markets it is wide, so do not rank on the point value. * **`realized_pnl_usd` is realized only.** An open position contributes nothing until its market resolves. Markets that resolved *invalid* are excluded from P\&L, hit rate and the resolved count. * **`open_positions` and `open_exposure_usd` understate.** A position is priced from a recent top-of-book quote (the last hour); one with no fresh quote is left out. * **Lists are capped.** `markets` and `open_positions` hold at most 30 rows, `trades` at most 100, however large `markets_count` is. `correlated` lists up to 8 wallets that trade many of the same markets. * **Not comparable with the L2 routes.** This reads the trade tape; [`/market_stats`](/docs/endpoints/data/polymarket/market-stats) and [`/ohlcv`](/docs/endpoints/data/polymarket/ohlcv) read the recorded book, so the same wallet's volume can differ between them. ## Errors [#errors] | Status | When | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | The address is not `0x` plus 40 hex digits, `window` is not one of the four values, an unknown query parameter is sent, **or the wallet has no recorded trades**. The body is `{"error": "…"}`. | | `401` / `429` | No or bad credential, or the hourly budget is spent — see [Authentication](/docs/data/authentication) and [Rate limits](/docs/data/rate-limits). | | `503` | The store behind it is unavailable. The body carries no driver text. | This is a heavier call than its single budget unit suggests: the `correlated` list alone scans hundreds of MiB. Cache the answer rather than polling it. # /v1/docs (/docs/endpoints/data/schema/docs) ``` GET https://data.vike.io/v1/docs ``` Swagger UI over the same document: the interactive, one-operation-at-a-time view, with an **Authorize** dialog that attaches your key to *Try it out* calls now that the spec declares `ApiKeyHeader`. No parameters. Serves `text/html`. A live call and what it returned, captured 2026-09-03. ```sh title="cURL" curl -H "X-API-KEY: $VIKE_API_KEY" https://data.vike.io/v1/docs ``` `200`, 1,018 bytes — and `text/html`, not JSON, so it is shown as the HTML it is. This is the complete body, verbatim; nothing is elided. ```html title="Example response" vike.io Data API - Swagger UI
``` That is the whole of it: a shell with no API data in it at all. Everything a reader recognises as Swagger UI arrives afterwards, from two `cdn.jsdelivr.net` script and stylesheet fetches and one fetch of the spec, all issued by the browser. Two lines in those bytes are worth reading against the warnings above. **`url: '/v1/openapi.json'` is host-relative**, so the browser fetches the spec from `data.vike.io` itself — a second request, behind the same header-only gate, carrying no `X-API-KEY` of its own. Getting this HTML rendered is therefore not sufficient: a header-injecting extension or proxy has to cover the spec fetch too, not just the page load. And **`oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect'`** is the un-moved route described below, present in the served bytes — the shell asks the browser to build a callback URL under the bare `/docs/` prefix, which this host does not route anywhere. ⚠ The browser trap on [Schema and spec](/docs/endpoints/data/schema) bites here first — this is the page people try to open by hand, and by hand it 401s. There is a second, harmless oddity behind it: the framework also mounts `/docs/oauth2-redirect`, its default OAuth2 authorization-code callback, and that route alone was never moved under `/v1/`. It is unreachable at the edge (measured 404 on 2026-09-03) and unused — the published `OAuth2Bearer` scheme is `type: http`, a token pasted into the Authorize box, which needs no redirect. What the document declares, what it leaves out, and the gate in front of it: [Schema and spec](/docs/endpoints/data/schema). # Schema and spec (/docs/endpoints/data/schema) Three surfaces describe the REST API, and all three live under `/v1/` on `data.vike.io`, served by the same application as the operations they describe. They sit under `/v1/` rather than at the framework's default bare paths, because `/v1/` is the only prefix that reaches that application on this host — the bare paths are routed nowhere and answer 404. Both halves of that placement are pinned by tests, so neither moves quietly. All three are unauthenticated **at the application** and gated **at the edge**: the credential check covers the whole `/v1/` prefix, with no exemption for the schema paths. Measured with no credential on 2026-09-03: `/v1/openapi.json`, `/v1/docs` and `/v1/redoc` each answered **401**; `/openapi.json`, `/docs` and `/redoc` each answered **404**. The gate is **header-only**: exactly two headers are read, `Authorization` and `X-API-KEY`. 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. Swagger UI and ReDoc are HTML pages behind that header gate: typing either URL into a browser returns 401. Reach them with a header-injecting extension or proxy, or read the spec with `curl` and work from the file. Every request to these three paths is metered like any other `/v1` call and spends one unit of [the hourly budget](/docs/data/rate-limits), each under its own usage label. Fetching the spec is a normal API call, not a free one. ## The three surfaces [#the-three-surfaces] | Path | What it is | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | [`/v1/openapi.json`](/docs/endpoints/data/schema/openapi-json) | The live OpenAPI 3.1.0 document for the `/v1` REST API — the machine-readable surface. | | [`/v1/docs`](/docs/endpoints/data/schema/docs) | Swagger UI over the same document: the interactive, one-operation-at-a-time view. | | [`/v1/redoc`](/docs/endpoints/data/schema/redoc) | ReDoc over the same document: one scrollable reference page rather than a request console. | ## What the spec declares [#what-the-spec-declares] Counted on 2026-10-17 from the application's own `openapi()` — the document `/v1/openapi.json` serves, which is behind the key gate and was not fetched for this count. It has changed before and will again: the Polymarket routes joined it on 2026-09-03, `/wallets/{address}/defi` (2026-09-04) and `/funds/holdings` (2026-10-16) left it, and two Polymarket trader routes were added (2026-10-16). | Field | Value | | ----------------------------------- | -------------------------------------------------------------------------------------- | | OpenAPI version | `3.1.0` | | `info.title` / `info.version` | `vike.io Data API` / `1.0.0` | | `servers` | one entry, `https://data.vike.io` | | Paths / operations | 61 / 61 — 60 `GET` and one `POST` (`/v1/onchain/labels/batch`) | | Namespace split | hyperliquid 35 · onchain 15 · polymarket 11 — all of them documented in this reference | | Operations carrying a `summary` | 61 of 61 | | Operations carrying a `description` | **47 of 61** | | Declared parameters | 258, of which **89** carry a description | | `components.schemas` | **2** — `HTTPValidationError` and `ValidationError` | | Declared response codes | `200` on all 61, `422` on 58 | | 2xx bodies declaring a schema | **0** | | `securitySchemes` | `OAuth2Bearer`, `ApiKeyHeader` | Those counts are reproducible from the live document — this prints six of the rows above, and run against the document as captured on 2026-09-03 it agreed with every one of them. ```sh title="cURL" curl -s -H "X-API-KEY: $VIKE_API_KEY" https://data.vike.io/v1/openapi.json | jq '{ paths: (.paths | length), described: [.paths[][] | select(.description)] | length, params: [.paths[][].parameters // [] | .[]] | length, params_described: [.paths[][].parameters // [] | .[] | select(.description)] | length, schemas: (.components.schemas | keys), codes: [.paths[][].responses | keys[]] | unique }' ``` ```json title="Example response" { "paths": 61, "described": 47, "params": 258, "params_described": 89, "schemas": [ "HTTPValidationError", "ValidationError" ], "codes": [ "200", "422" ] } ``` Every path in the document starts with `/v1/`. Internal operational routes are excluded from the spec by design; nothing outside `/v1/` is API a key-holder can call. The three surfaces in this namespace are excluded too, so the document does not describe itself. Every one of its 61 operations is a `hyperliquid`, `onchain` or `polymarket` endpoint these pages document, so the whole-document counts above describe exactly this reference's REST surface. Parameters are the part the spec does carry: all 258 declare a name, a location, a type and whether they are required, which is enough for a generator to build correct request signatures. Only 89 of them carry prose explaining what they do. Three operations declare no parameters at all — `/v1/hyperliquid/cohorts`, `/v1/hyperliquid/segments` and `/v1/hyperliquid/positions/coins`. **The auth block is real, and it is what makes generation viable at all.** The document publishes both credentials — `ApiKeyHeader` (`apiKey`, in `header`, named `X-API-KEY`) and `OAuth2Bearer` (`http`/`bearer`, `bearerFormat: vk_oat_...`) — and the top-level `security` is a **list** of single-key requirements, which in OpenAPI means *either*, not *both*. A generated client therefore sends a credential header instead of 401ing on its first call. It is schema only: it declares the auth the API already enforces, and adding or removing a scheme cannot change whether a call is accepted. No operation opts out with `security: []`, and a test holds that true. Both schemes landed on 2026-09-02; before that the document declared no authentication at all, on a key-gated, per-call-billed API. ## The four gaps [#the-four-gaps] Be clear-eyed about these before planning to generate anything from the document. **1. There are no response schemas.** `components.schemas` holds exactly two entries and both are error types, reached only from the `422` response that 58 operations declare. (The three operations with no `422` are exactly the three that take no parameters, so there is nothing to validate.) Every `200` in the document is `{"schema": {}}`, an undeclared object, so a generated client gives you typed *requests* and untyped *responses*. That is a deliberate property of the API rather than an oversight in the export: responses are undeclared objects, and no count of "schema gaps" can make them otherwise. Where a response field matters, these pages state it; the document will not. **2. Fourteen operations carry no description.** The gap is not spread evenly: onchain is missing 13 of 15, hyperliquid 1 of 35 and polymarket none of 11. Every operation has a `summary`, so nothing is nameless, but across most of `onchain` the summary is all there is. The namespace pages in this reference are where that prose lives. **3. Only success and validation failure are declared.** The document declares `200` and `422` and nothing else, while the API really returns `400` for rejected input, `401` for a bad or missing credential, `429` for the hourly limit and `503` for an upstream failure — see [Errors](/docs/data/errors). Error handling is the part of a generated client you write yourself, and a generator that treats an undeclared status as a protocol violation will treat a normal rate-limit trip as one. **4. It covers 61 of the 68 endpoints in this reference.** Every one of the missing 7 is missing for a structural reason, not because someone forgot: | Absent from the spec | Count | Why | | ------------------------------------ | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | [archive](/docs/endpoints/data/archive) | 3 | Files served directly. There is no application operation to describe. | | [stream](/docs/endpoints/data/stream) | 1 | A different application on a different host (`vike.io`), which this document does not cover — and OpenAPI has no WebSocket operation to emit in any case. | | The three surfaces in this namespace | 3 | Excluded from the document by design. | Nothing under `/v1/` is readable without a credential, the document included. A prospective user cannot inspect the API's shape before holding a key, and no public copy is published today — see below. If you need to show someone the surface without giving them a key, send them these pages. ## `data.vike.io/openapi.json` is not shipped [#datavikeioopenapijson-is-not-shipped] There is no keyless copy of the spec at the bare path on this host. **That URL does not work.** ``` $ curl -s -o /dev/null -w '%{http_code}\n' https://data.vike.io/openapi.json 404 ``` Measured 2026-09-03. The host root behaves the same way: `https://data.vike.io/` returns 404 too — nothing but `/v1/` and `/archive/` is routed on this host. Use [`/v1/openapi.json`](/docs/endpoints/data/schema/openapi-json) with a key; it is the only published copy, and no curated public variant exists today. ## Related [#related] * [Authentication](/docs/data/authentication) — the two credentials, and where to create a key. * [Rate limits](/docs/data/rate-limits) — the one hourly counter these three surfaces spend from. * [Errors](/docs/data/errors) — the status codes the spec does not declare. * [Endpoint overview](/docs/endpoints/data) — the namespace table every endpoint in this reference sits in. # /v1/openapi.json (/docs/endpoints/data/schema/openapi-json) ``` GET https://data.vike.io/v1/openapi.json ``` The live OpenAPI 3.1.0 document for the `/v1` REST API. This is the machine-readable surface: fetch it once to generate a client or drive a request tool, or diff it after a deploy to see what moved. No path or query parameters — the only input that matters is the credential header. ```sh title="cURL" curl -H "X-API-KEY: $VIKE_API_KEY" \ https://data.vike.io/v1/openapi.json -o openapi.json ``` `200`, 84,924 bytes of `application/json` when captured on 2026-09-03. Too large to show whole, so it is split below into the three parts worth reading; the entries under `paths`, the two schemas under `components.schemas` and the one prose string noted below are elided, and every other byte shown is verbatim from that response. The envelope — the document with `paths` and `components` removed and one prose string elided. ```json title="Example response — the envelope" { "openapi": "3.1.0", "info": { "title": "vike.io Data API", "description": "", "version": "1.0.0" }, "servers": [ { "url": "https://data.vike.io" } ], "security": [ { "OAuth2Bearer": [] }, { "ApiKeyHeader": [] } ] } ``` ⚠ `info.description` is the one string elided from the envelope above. It is a hand-written summary the document carries rather than derives, and it has fallen behind the routes the application actually serves — it still names surfaces that were removed. Read `paths` for what the API covers, or the namespace pages under [API endpoints](/docs/endpoints/data); do not read that string. `components.securitySchemes`, the block that makes client generation viable — both credentials, each carrying its own prose, and a top-level `security` (above) that is a **list** of single-key requirements, i.e. *either*, not *both*: ```json title="components.securitySchemes" { "OAuth2Bearer": { "type": "http", "scheme": "bearer", "bearerFormat": "vk_oat_...", "description": "OAuth 2.1 access token: `Authorization: Bearer vk_oat_...`. Issued by vike.io — discovery at `https://vike.io/.well-known/oauth-authorization-server`. Tried first; an invalid bearer falls through to the API-key header, so a request may carry either or both." }, "ApiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-KEY", "description": "Long-lived API key: `X-API-KEY: vk_...`. Create and revoke keys at https://vike.io/account?tab=api-keys. Every call is metered against the key and subject to an hourly rate limit; a trip is HTTP 429 with a `Retry-After` header." } } ``` `components.schemas` is elided: it holds exactly the two entries the table on [Schema and spec](/docs/endpoints/data/schema) names, `HTTPValidationError` and `ValidationError`, and both are the framework's own request-validation error shapes. One entry under `paths`, verbatim — `/v1/onchain/wallets/{address}`, chosen because it is the smallest complete operation in the document and shows three of the four gaps at once: ```json title="One entry from paths" { "get": { "tags": [ "onchain" ], "summary": "Wallet Summary", "operationId": "wallet_summary_v1_onchain_wallets__address__get", "parameters": [ { "name": "address", "in": "path", "required": true, "schema": { "type": "string", "title": "Address" } } ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": {} } } }, "422": { "description": "Validation Error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HTTPValidationError" } } } } } } } ``` The parameter is fully typed and marked `required`, which is the part a generator can use. But there is no `description` on the operation (it is one of the 14 without) and none on the parameter (one of the 169 without); the `200` body is `"schema": {}`, an undeclared object; and the only other declared response is the `422`, whose `$ref` reaches one of the two error schemas. A generated client gets a correct request signature and an untyped response — [the four gaps](/docs/endpoints/data/schema#the-four-gaps) is the fuller account. The table on [Schema and spec](/docs/endpoints/data/schema#what-the-spec-declares) carries the current counts, taken on 2026-10-17 from the application's own `openapi()` that this document is built from: 61 paths and 61 operations (60 `GET`, the one `POST` being `/v1/onchain/labels/batch`), the 35 · 15 · 11 namespace split, 47 of 61 descriptions, 258 parameters of which 89 are described, `200` on all 61 and `422` on 58, zero 2xx bodies declaring a schema, and no declared status code anywhere in the document other than those two. It is generated, not hand-written, and it cannot drift from the routes that serve it: it is the framework's own document with the two security schemes added, then cached. Fetching it queries no data store, so it costs a request and nothing more. What the document declares, what it leaves out, and the gate in front of it: [Schema and spec](/docs/endpoints/data/schema). # /v1/redoc (/docs/endpoints/data/schema/redoc) ``` GET https://data.vike.io/v1/redoc ``` ReDoc over the same document: one scrollable reference page rather than a request console. Prefer it for reading, [`/v1/docs`](/docs/endpoints/data/schema/docs) for calling. No parameters. Serves `text/html`. A live call and what it returned, captured 2026-09-03. ```sh title="cURL" curl -H "X-API-KEY: $VIKE_API_KEY" https://data.vike.io/v1/redoc ``` `200`, 900 bytes — `text/html`, not JSON, so it is shown as the HTML it is. This is the complete body, verbatim; nothing is elided. ```html title="Example response" vike.io Data API - ReDoc ``` The served bytes are the thinnest of the three surfaces — 118 bytes less than [`/v1/docs`](/docs/endpoints/data/schema/docs) — and they carry the reading-versus-calling split in a single element. **``** is the entire application: no Authorize dialog, no `oauth2RedirectUrl`, no request console, nothing that could submit a call. That is the difference between this page and Swagger UI stated in markup rather than in prose. `spec-url` is host-relative, exactly as on `/v1/docs`, so the same consequence follows: the browser issues a second request to `data.vike.io` for the spec, and that request is behind the same header-only gate and carries no `X-API-KEY` of its own. Rendering this HTML is not enough on its own. Note also the `
../
README.txt                                         03-Sep-2026 00:32                1376
book_events.parquet                                03-Sep-2026 00:32             6095263
l1_quotes.parquet                                  03-Sep-2026 00:32              631242
trades.parquet                                     03-Sep-2026 00:32               55905