Skip to content

VGP v1 wire format — design

Status: design for phase 44 (amends 2026-09-30-futures-options-design.md §7); capabilities and perpetual funding added by 2026-10-04-perpetual-funding-and-venue-capabilities.md, mark prices by 2026-10-04-mark-and-index-stream-fields.md, open interest by 2026-10-04-open-interest.md, option marks by 2026-10-05-option-marks-as-rule-inputs.md, the public tape and liquidations by 2026-10-05-trade-flow-and-liquidations.md, order-book depth by 2026-10-05-order-book-depth.md. The endpoint list and semantics come from docs/research/2026-09-18-venue-plugin-architecture.md §6; this document fixes what that list left to implementations: the JSON, the event stream, errors, resume and the kill switch. The qkt client (connector/gateway) and any gateway (qkt-venue-gateway) implement exactly this; the client's fake gateway in tests is built from it.

1. Conventions

  • Transport: HTTPS (HTTP in tests) under a base URL; every path starts /v1/. JSON bodies, UTF-8, Content-Type: application/json.
  • Auth: Authorization: Bearer <token> on every request, the stream included. A missing or wrong token is 401 unauthorized; on a WebSocket route (/v1/stream, /v1/quotes) the 401 answers the upgrade request, so no socket opens.
  • Money and quantities are decimal strings ("0.10", "84042.83"), never JSON numbers: qkt books exact BigDecimal, and a float on the wire would round. Integers (sequence numbers, times) are JSON numbers.
  • Time: UTC epoch milliseconds, JSON numbers. Broker-time offsets are the gateway's concern.
  • Symbols are the gateway's venue codes (BTC_USDC-25DEC26-92000-C, BTCUSDT_241227); the client maps them to qkt symbols with the account's symbolPrefix (DERIBIT: + code, - written _, as OptionSymbols does).
  • Enums are lowercase strings. Unknown fields are ignored by both sides; an unknown enum value is an error the receiver reports, never a guess.

2. Errors

Every non-2xx response has the body {"error": {"code": "<code>", "message": "<text>"}}.

HTTP code Meaning
400 invalid_request malformed body or field; never retried
401 unauthorized bad or missing token
404 not_found no such order, instrument or root
409 conflict client_order_id reused with a different body
422 venue_rejected the venue refused the request; message is its reason
423 kill_switch the request adds or changes risk while a kill scope covers its symbol
501 unsupported the gateway's adapter does not serve what was asked (a capability it does not declare)
503 venue_unavailable the gateway cannot reach the venue; safe to retry reads, and submits (idempotent on client_order_id)

3. Endpoints

GET /v1/health

{"protocol": "vgp1", "adapter": "deribit", "adapter_version": "0.1.0",
 "account_login": "12345", "trade_mode": "demo", "venue_connected": true,
 "kill_switch": {"all": false, "symbols": []}, "server_time": 1790835316271,
 "stream": "4f1c...", "seq": 1041, "capabilities": ["bars", "funding", "funding_rates", "quotes"]}
stream and seq are the event log's identity and its latest sequence number: a client that reads them before reconciling from REST, then opens the stream with since=<seq>, loses nothing that happened in between. trade_mode is demo or real. The client checks protocol, adapter, account_login and trade_mode against the account's expected_* settings before trading.

capabilities names the optional services the gateway's adapter serves: bars (/v1/bars), quotes (/v1/quotes), settlements (the venue's settlements reach /v1/settlements and the stream), funding (what the venue charged or credited the account for holding perpetuals: /v1/funding and the funding event), funding_rates (/v1/funding-rates), mark_prices (/v1/marks, and quotes that carry mark and index where the venue reports them), open_interest (/v1/open-interest), option_marks (option quotes that carry mark_iv and underlying, §4a), trades (/v1/trades), liquidations (/v1/liquidations) and depth (/v1/depth). An endpoint whose capability is not declared answers 501 unsupported (/v1/settlements is still served, from what the journal holds). A gateway that predates the field omits it; a client treats that as none declared. A client ignores a name it does not know. Funding is the cash a venue moves between longs and shorts of a perpetual; a client books it, so it does not trade perpetuals on a gateway that does not declare funding. A client reads a contract's mark and index (as strategy inputs) only from a gateway that declares mark_prices, an option's implied volatility and Greeks only from one that declares option_marks, a contract's traded or liquidated volume only from one that declares trades or liquidations, and a contract's order-book depth only from one that declares depth.

GET /v1/account

{"currency": "USDC", "balance": "10000", "equity": "10012.5", "margin_used": "265",
 "margin_available": "9747.5", "initial_margin": "265", "maintenance_margin": "190"}

GET /v1/instruments and GET /v1/instruments/{code}

A list (or one) of:

{"code": "BTC_USDC-25DEC26-92000-C", "kind": "option", "currency": "USDC",
 "contract_size": "1", "tick_size": "5", "volume_step": "0.01", "volume_min": "0.01",
 "expiry": 1798185600000, "strike": "92000", "right": "call", "underlying": "BTC_USDC"}
kind is spot, perpetual, future or option; expiry is absent for spot and perpetuals; strike, right and underlying only for options. contract_size is how many units of the underlying one unit of quantity is, the multiplier of P&L (quantity × contract_size × price change); volume_step is the increment a quantity moves in and volume_min the smallest. A venue that takes amounts in the base coin (Deribit's linear contracts) lists contract_size 1 and its own contract size as volume_step. A dated contract stays listed for at least 30 days after its expiry, so a client restarting after a weekend still maps the contracts it held to their settlements. (The qkt client also maps an unlisted option by its name, which is exact; a dated future has no such rule.)

GET /v1/bars?symbol=<code>&window_ms=<ms>&from=<ms>&to=<ms>

Closed bars of one instrument, oldest first, each start in [from, to) and aligned to UTC:

{"bars": [{"start": 1790812800000, "open": "84010.5", "high": "84102", "low": "83990", "close": "84050.5",
  "volume": "12.31"}], "next": 1790872800000}
window_ms is a whole number of minutes that divides a day (60000, 300000, 3600000, 86400000…). A response holds at most 1000 bars; next is the from of the following page and is absent on the last one. The gateway serves the venue's own klines where it has them (aggregated from 1-minute ones for other windows), else builds them from the venue's trades; a window with no trade has no bar. The client reads them for live warmup, and to measure a continuous stream's roll live by the same rule as qkt fetch --rolls (each contract's last 1-minute close at or before the roll).

Futures chains (not in v1)

A dated future carries no root on the wire (underlying is for options only), so v1 serves no futures chain. The chain endpoint is designed with its first consumer, live continuous futures (qkt phase 46), together with how a future names its root.

GET /v1/positions

{"accounting": "netting", "positions": [<Position>]} with

{"symbol": "BTC_USDC-25DEC26-92000-C", "quantity": "-0.1", "avg_price": "646",
 "ticket": null, "opened_at": 1790835377133}
accounting is netting (one signed position per symbol, ticket null) or hedging (one per ticket).

GET /v1/orders

{"orders": [<Order>]}, the working orders placed through this gateway. Orders another tool placed on the same account are not listed, as the stream carries none of their events. An Order is:

{"client_order_id": "dsl-s-1", "venue_order_id": "8812", "symbol": "BTC_USDC-25DEC26-92000-C",
 "side": "sell", "type": "limit", "quantity": "0.1", "limit_price": "650", "stop_price": null,
 "time_in_force": "gtc", "reduce_only": false, "status": "working", "filled_quantity": "0",
 "avg_fill_price": null, "reject_reason": null, "created_at": 1790835316271, "updated_at": 1790835316271}
type: market, limit, stop, stop_limit. time_in_force: gtc, ioc, fok, day. status: working, filled, cancelled, rejected. filled_quantity is cumulative.

GET /v1/orders/{client_order_id}

The Order in whatever state it is now (working, filled, cancelled, rejected), or 404 not_found when the gateway never placed it. After answering 404 for an id, the gateway refuses that id for ever (409 conflict), so a submit delayed in the network can never place an order the client has already written off. The gateway keeps an order for at least 30 days after it ends. A client resolves an order whose fate it missed (a submit it never heard back from, an order that ended while it was away) by asking for it here.

POST /v1/orders

Body: the Order fields a client sets (client_order_id, symbol, side, type, quantity, limit_price, stop_price, time_in_force, reduce_only). Response 201 with the Order.

Idempotent on client_order_id: a second POST with the same id and the same body returns 200 with the existing Order in its current state and never places a second order; the same id with a different body is 409 conflict. A client that saw a timeout resubmits the same body.

A market order never rests. An order of type market, or a stop once triggered, fills what the venue offers at that moment; any remainder is cancelled, never left working, whatever its time_in_force. The order then ends cancelled with its filled_quantity, and the fills already booked stand. A gateway whose venue leaves such a remainder working (Deribit turns it into a limit at its price-band edge) cancels it as soon as it sees it, and reports the order with the type the client sent. The client never sends the remainder again on its own: whether to trade it is the strategy's decision.

A client_order_id is at most 64 characters (it travels in the venue's order label) and the gateway remembers it for ever, so a client never sends one twice for different orders. The qkt client sends its engine order id with the order's submit time appended (dsl-s--4.mg8q2kv1): an engine may hand an id out again after a restart, the submit time never repeats, and a restored order's id is rebuilt from its persisted request.

PATCH /v1/orders/{client_order_id}

Body: any of quantity, limit_price, stop_price. Response 200 with the Order. Gated by the kill switch unless it only reduces quantity.

DELETE /v1/orders/{client_order_id}

Response 200 with the Order (cancelled, or filled if the fill won the race: the client keeps the fill). Never gated.

POST /v1/positions/close

Body {"symbol": "<code>", "quantity": "<decimal>", "client_order_id": "<id>"} (quantity optional: the whole position; client_order_id optional) or {"ticket": "<ticket>"}. Response 200 with the closing Order. Never gated: flatten is always allowed.

With client_order_id (the same rules as a submit's: at most 64 characters, never reused) the close is idempotent on it: the closing order is sent under that id, and a second request with the same id, symbol and quantity (or none) returns 200 with that order in its current state, even once the position is flat, and never closes again; the same id with another symbol or quantity, or an id an order was sent with, is 409 conflict. A client that saw a timeout resends the same body. Without it the gateway names the order itself and every request is a new close, so a retry may close twice.

GET /v1/deals?from=<ms>&to=<ms> and GET /v1/deals?client_order_id=<id>

{"deals": [<Fill>]}, oldest first (§4 fill): the executions in the window, or every execution of one order (complete for as long as the gateway keeps the order).

GET /v1/settlements?from=<ms>&to=<ms> and GET /v1/settlements?symbol=<code>

{"settlements": [<Settlement>]}, oldest first (§4 settlement): the settlements in the window, or the settlement of one contract (empty until it expires). A client reconciling after an outage reads the ones it missed here; for contracts its strategies still hold, it asks by symbol.

GET /v1/funding?from=<ms>&to=<ms>

{"funding": [<Funding>]}, oldest first (§4 funding): what the venue charged or credited the account for holding perpetuals in the window. A client reconciling after an outage reads the records it missed here. 501 unsupported unless funding is declared.

GET /v1/funding-rates?symbol=<code>&from=<ms>&to=<ms>

A perpetual's published funding rates, oldest first, each time in [from, min(to, now)]:

{"rates": [{"time": 1791126000000, "rate": "0.0000418", "price": "121.69"}], "next": 1794726000000}
A unit long held through time pays rate × price per unit of the underlying (times the instrument's contract_size); a negative rate pays the short. price is the price the venue applied (its mark or index), absent when it publishes none, and the client then uses its own last price. A response covers at most 1000 hours and holds at most 1000 rates; next is the from of the following page and is absent on the last one. The client stores these for backtests (qkt fetch --funding). 501 unsupported unless funding_rates is declared.

GET /v1/marks?symbol=<code>&window_ms=<ms>&from=<ms>&to=<ms>

One contract's mark and index price history, sampled once per window: for each closed window starting in [from, to) in which the venue reported them, the last report in it, at its own time, oldest first:

{"marks": [{"time": 1791154649967, "mark": "86432.49", "index": "86403.5"}], "next": 1791160640000}
window_ms follows /v1/bars. mark is the price the venue values positions and liquidates at, index the spot index it tracks (the same fields as a quote's, §4a); either is absent when the venue did not report it. A window in which the venue reported nothing has no entry: the value known is still the previous one. A response covers at most 100 windows; next is the from of the following page and is absent on the last one. The client stores these for backtests (qkt fetch --marks), which see each value from its time on. 501 unsupported unless mark_prices is declared.

GET /v1/open-interest?symbol=<code>&from=<ms>&to=<ms>

A contract's published open interest, oldest first, each time in [from, min(to, now)]:

{"open_interest": [{"time": 1790864291655, "open_interest": "1477.6341"}], "next": 1790924291655}
open_interest is the contracts outstanding, in the contract's order quantity (the unit an order's quantity is written in). time is the instant the venue made the figure known, never earlier: a figure the venue stamps with the start of the period it summarizes is served at that period's end. A response covers at most 1000 minutes and holds at most 1000 figures; next is the from of the following page and is absent on the last one. A venue that publishes no history serves the figures its adapter recorded (the adapter's README says so); such a series starts when the gateway first recorded it. A gateway may keep a recorded series for a bounded window only (a retention its adapter's README names, off by default): what falls out of the window is gone for good, and a read reaching before it answers what remains, never an error. The client stores these for backtests (qkt fetch --open-interest), fetching within the gateway's window, and reads the newest live. 501 unsupported unless open_interest is declared.

GET /v1/trades?symbol=<code>&from=<ms>&to=<ms>

One contract's public trade tape: every print whose time is in [from, min(to, now)), oldest first:

{"trades": [{"id": "USDC-55597050", "time": 1791154801271, "price": "86457.1", "size": "0.005",
  "side": "buy"}], "next": 1791154873114}
side is the aggressor's, the side that took liquidity (buy lifted an offer). size is in the contract's order quantity (the unit an order's quantity is written in). id is the venue's trade id, unique per contract. A response holds at most 1000 prints; a full one ends before its last millisecond, whose prints all open the next page, so next (absent on the last page) is that millisecond and no print is served twice. The client stores these for backtests (qkt fetch --tape). 501 unsupported unless trades is declared.

GET /v1/liquidations?symbol=<code>&from=<ms>&to=<ms>

The prints of one contract that liquidated a position, time in [from, min(to, now)), oldest first, in the shape of /v1/trades:

{"liquidations": [{"id": "USDC-65965358", "time": 1791096882272, "price": "85070.2", "size": "0.0011",
  "side": "buy"}], "next": 1791100482272}
side is the liquidation order's: sell closed a liquidated long, buy a liquidated short. A print that liquidated both its buyer and its seller is two entries, one of each side, under the same id. Where the venue marks liquidations on its tape, each is also one of /v1/trades' prints. A response covers at most one hour (finding liquidations may cost a read of the whole tape); next is the from of the following page and is absent on the last one. The client stores these for backtests (qkt fetch --liquidations). 501 unsupported unless liquidations is declared. The account's own liquidations are fills on the stream, not these.

GET /v1/depth?symbol=<code>&from=<ms>&to=<ms>

A contract's order book as the gateway recorded it, snapshots oldest first, each time in [from, min(to, now)]:

{"depth": [{"time": 1791170721647, "bids": [["86490.6", "10"], ["86487", "0.005"]],
  "asks": [["86490.7", "10"]]}], "next": 1791230721647}
A snapshot holds at most the best 10 price levels of each side: bids from the highest price down, asks from the lowest up, each level [price, amount], amount the quantity resting at that price in the contract's order quantity. A side the book does not hold is []. time is the instant the venue stamped the book, never earlier. Venues publish no book history, so a gateway serves the snapshots its adapter recorded: a read whose window reaches the present (within a minute) first records the venue's book as it stands, then serves what was recorded. A series therefore has one snapshot per such read (qkt's live feed reads every 10 seconds), starts when the gateway first recorded it, and has a gap wherever nothing read it. Paging is as for open interest: at most 1000 minutes and 1000 snapshots a response, next the from of the following page, absent on the last. As there, a gateway may keep the record for a bounded window only, and a read reaching before it answers what remains. The client stores these for backtests (qkt fetch --depth), fetching within the gateway's window, and reads the newest live. 501 unsupported unless depth is declared.

POST /v1/kill, POST /v1/kill/release

Body {"scope": "all"} or {"scope": "symbols", "symbols": ["<code>", ...]}. Response 200 with the kill_switch object of /v1/health. While a scope covers a symbol, POST /v1/orders is 423 kill_switch unless it is reduce_only: true and no larger than the account's position on the opposite side (checked by the gateway, whatever the venue enforces), and a PATCH that is not a pure reduction is 423 too. Cancels and positions/close always pass.

4. Event stream: GET /v1/stream?since=<seq>&stream=<stream> (WebSocket)

Each message is one event:

{"stream": "4f1c...", "seq": 1042, "type": "fill", "time": 1790835377133, "data": {...}}

  • stream identifies the gateway's event log; it changes when that log restarts (and seq with it).
  • seq increases by exactly 1 per event within a stream.
  • since is exclusive: the gateway first replays every retained event with seq > since, in order, then streams live. With no since, it streams live only.
  • stream, optional, is the stream the client's since belongs to (of the last event it processed, or of the health anchor). Without it a since from a previous stream is recognized only when it is beyond the current log's latest seq; a client resuming sends it.
  • If since is older than the oldest retained event, or the client's stream is not the current one (stream names another, or since is beyond the latest seq), the gateway sends a single {"type": "reset", "stream": "<current>", "seq": <latest>} and then streams live from there. The client must then resynchronize from REST (§5).

Event type and data:

type data
order an Order (§3): every status change
fill {"client_order_id", "venue_order_id", "fill_id", "symbol", "side", "quantity", "price", "time", "costs": [{"kind", "amount", "currency"}]}
settlement {"symbol", "price", "time", "costs": [{"kind", "amount", "currency"}]}
funding {"funding_id", "symbol", "amount", "currency", "position", "time"}
position a Position (§3) after a change, quantity "0" when flat
account the /v1/account object
kill the kill_switch object
  • fill.quantity is that fill's quantity; an order's cumulative quantity is in its order event.
  • fill_id is unique per venue execution, so a replayed fill is recognized.
  • costs[].kind is commission, exchange_fee, delivery_fee, funding or swap; an adapter that cannot report a cost omits it rather than reporting zero. costs[].amount is positive when the venue charged the account and negative when it credited it (a maker rebate).
  • A fill is always an execution of an order the client sent. Expiry is not a fill: when a contract the account holds or has traded expires, the gateway sends one settlement with the price every holder settles at (an option's intrinsic value, a future's delivery price) and the costs the venue charged the account. It is sent even when the account's net position is zero, so a client that runs several strategies on one account settles each one's own holding.
  • A funding is the venue charging (amount positive) or crediting (amount negative) the account for holding perpetual symbol, at time, in currency. position is the account's signed quantity the venue charged on (absent when the venue does not say), so a client running several strategies, or sharing the account with another tool, books only its own part. funding_id is unique per venue record, so a record seen twice is one record. A venue that funds continuously reports funding when it realizes it (Deribit: at its daily settlement and when a position is reduced).
  • v1 defines no liquidation event (qkt has no liquidation semantics yet).
  • A client ignores, logging it, an event type it does not know, so events can be added without a new protocol. (qkt 0.55 and earlier predate this rule and stop on a funding event: upgrade the client with the gateway.)

4a. Quotes: GET /v1/quotes?symbols=<code>,<code>&roots=<root>,<root> (WebSocket)

Market data, apart from the event log: quotes are latest-wins and high volume, so they carry no seq, are never replayed, and a reconnect simply subscribes again. roots subscribes every listed option of a root (an instrument's underlying), including options listed after the subscription; a code subscribed both ways is sent once. Each message is one quote:

{"symbol": "BTC_USDC-25DEC26-92000-C", "bid": "640", "ask": "655", "bid_size": "1.2", "ask_size": "0.8",
 "mark": "648.5", "mark_iv": "52.3", "underlying": "84437.55", "time": 1790835377133, "index": "84301.2"}
Any field but symbol and time may be null (no bid, no mark yet). mark_iv is in volatility points. underlying is the price the contract is valued against (an option's forward for its expiry); index is the spot index the venue charges fees on (Deribit's option fee is a share of it). A gateway declaring option_marks sends both mark_iv and underlying on an option's quotes once the venue reports them; the client prices the option's Greeks from them (Black-76, rate 0), so the venue's own Greeks are not carried.

time is the instant the gateway last knew the quote to hold, not when it last changed. While a subscribed quote holds, the gateway sends it again with time advanced at least every 5 seconds: venues push options only when their book changes, and a quiet book must stay distinguishable from a stopped feed. The client's market-data gate judges each contract by these refreshes, so a contract whose refreshes stop is stale and new orders on it wait.

5. Client behaviour

  • Start: GET /v1/health (identity checks, and the stream's stream/seq as the anchor), then /v1/account, /v1/positions, /v1/orders, /v1/deals and /v1/settlements to reconcile, then open the stream with since=<anchor seq>&stream=<anchor stream>; what REST already reported is recognized and dropped.
  • Reconnect: reopen with since=<last seq>&stream=<its stream>; events with seq <= the last processed one are dropped, and so is a fill whose fill_id was already booked.
  • Reset: on a reset event, or a stream change, reconcile from /v1/orders, /v1/positions, /v1/deals and /v1/settlements since the last processed fill (and /v1/funding since the last booked funding record, when declared), book missing fills by fill_id, missing settlements by symbol and time and missing funding by funding_id, then continue from the reset's seq.
  • Submit: POST once; on a timeout or 503, resubmit the same body (idempotent); on 422 or 423 the order is rejected with the gateway's message.
  • Several strategies on one account: one stream and one client per account. Fills are attributed by client_order_id; a settlement reaches every strategy, each settling its own holding, and its costs are shared once, by holding, across them.
  • Restart: every order the client restores is resolved by id (GET /v1/orders/{id} and its deals); fills it booked before the restart are matched against its booked quantity, oldest first, in that complete history. Settlements of contracts still held are read by symbol. Funding since the last record it booked (persisted) is read from /v1/funding; a first start books none from before it.
  • Unanswered submit: resend the same body until a deadline, then resolve it by id: 404 means it was never placed.