Voltnir is middleware that sits between your trading stack and the EPEX SPOT M7 intraday market. It owns the exchange connection, using AMQP for public/private feeds and WebSocket for market data, and handles everything that comes with it.
Internally, it maintains exchange state and exposes it to your systems over REST, WebSocket, and gRPC. It ships as a single static binary, developed in Rust for memory safety and speed, with no garbage collector pauses in the hot path.
This page explains, in detail, how each part of it works.
The page is written for three audiences. Traders will find how the order book is maintained and how position limits, cash limits, and real-time P&L are calculated. Risk and compliance will find how every order, trade, and account change is logged, attributed to a user, and retained for audit. Engineering and operations will find the transport contracts, the deployment model, and how the gateway behaves when a feed degrades or fails. Read it end to end, or jump to the section that matches your role.
Before you can place an order you need a book to place it against. Voltnir owns the exchange connection and maintains that book for you: it applies each M7 update as the exchange publishes it and keeps the order book, the public trade tape, and the cross-border capacity matrix in memory, so every client reads a current, consistent picture without each one parsing the exchange feed. The same data is served three ways: WebSocket subscriptions, gRPC server-streams, and point-in-time REST reads.
Two upstream transports. Voltnir reaches M7 over a WebSocket connection and an AMQP connection, each carrying a public and a private side. The WebSocket runs over mutual TLS and carries the market_data and private_data streams; AMQP carries a public broadcast channel and a per-session private channel. Voltnir supervises every connection with automatic reconnect, exponential backoff, and primary/secondary host failover.
Snapshot, then deltas. On connect, M7 sends one full order-book snapshot per subscribed (delivery_area, product) key, a SynchronizationComplete marker, then incremental deltas. Voltnir holds the complete book in memory and recomputes top of book (best bid and best ask, with the cumulative size resting at that price) on every change. Clients receive the same model and derive whatever depth they need by sorting.
Sequenced, or resynced. Every snapshot and delta carries a sequence number that must be exactly one past the last, per (delivery_area, product). A gap or duplicate is treated as corruption: Voltnir drops the stream, marks the feed unsynchronised, and reconnects for a fresh snapshot rather than serve a torn book. Liveness is tracked by heartbeat (a missed-pong window), not by delta arrival, so a genuinely quiet market is never mistaken for a dead feed.
Contracts. A contract is one delivery period of a product in a delivery area, identified by (delivery_area, product, delivery_start) and addressable by its M7 contract id. Delivery areas are EIC codes; duration is carried verbatim as decimal hours (0.25, 0.5, 1.0). Voltnir stores only the delivery areas you configure. Once a contract's delivery end passes it is marked inactive and a delete is pushed to subscribers; an hour later it is tombstoned (its book cleared but its identity and trade history retained), so settled-contract P&L still resolves.
The public trade tape. Executed public prints are deduplicated by trade id and held in a rolling in-memory window of about twelve hours; on connect Voltnir backfills the last six hours so a fresh start is not blind. Each print carries price, quantity, execution time, the buy and sell delivery areas, a self-trade flag, and a state: ACTI is the active default state, while the recall and cancellation states (CNCL, RGRA, and related) mark a print the exchange has undone.
Cross-border capacity. The hub-to-hub feed is the XBID available-transfer-capacity (ATC) matrix between delivery areas: how many megawatts can still flow each way across each border. Capacity is passed through in M7's units (megawatts, and it can be negative), enriched with each border's current best bid and ask so the cross-zone spread reads in one feed, and resynced in full whenever its heartbeat drops. It is an opt-in feature; when disabled the matrix is simply empty.
contracts stream: opt-in per delivery-area and product, a full snapshot on subscribe then upserts and deletes. REST GET /api/v1/contract/{area} and /{area}/{contract_id} for a point-in-time read; gRPC ListContracts / GetContract unary and WatchContract server-stream.public_trades stream: opt-in, live deltas with no snapshot. Seed a view with the one-shot public_trades_for_contract command (or REST GET /api/v1/public_trades, gRPC ListPublicTrades), then dedupe live updates by trade_id.trade_tape stream: opt-in, with a 200-row chronological snapshot on subscribe. WebSocket-native; no REST or gRPC mirror.watch_hub2hub command: a snapshot then per-border updates, each carrying both areas' best bid and ask. REST GET /api/v1/hub2hub, gRPC GetHub2Hub. Empty when the feature is disabled.Authenticated, but unpermissioned. Reading market data needs a valid session and nothing more: no specific permission gates the book, the tape, or the capacity matrix on any transport, and none of it is exposed unauthenticated.
Capturing the feed. Market-data capture is off by default and configured under market_data as two independent streams: the public trade tape (public_trades) and the raw order-book frames (order_book, snapshots and deltas alike). Each opts in through its own persist setting, postgresql or parquet. A postgresql tape is written into and queried from the main audit database, which must itself be PostgreSQL or the gateway refuses to start; parquet writes export-only rotating files and works on any audit backend, SQLite included. Order-book capture is never queryable through the API; it is replay and backtest material only, written either to its own (or the audit database's inherited) PostgreSQL connection in daily partitions or to rotating Parquet files. Retention is per stream: the tape keeps one week by default, order-book capture keeps everything by default, and pruning only ever drops whole daily partitions or whole files, never row by row. Capture is fire-and-forget over a bounded channel: under overload it drops rows and logs rather than ever stall the live feed.
Feed health is a trading gate. The order-book feed's state (connected, synchronised, sequence-healthy, last-pong age) together with the measured per-delta processing time and end-to-end latency are exposed as rolling averages on GET /api/v1/state. The numbers are observed, not asserted. A new order is rejected with 503 when the feed is not green (§03), so an order is never sent against a stale or torn book.
Every order, regardless of entry point, passes through the same validation pipeline before anything touches the wire. A REST POST /api/v1/order, a WebSocket new_order command, and a gRPC SubmitOrder call all funnel into the same fixed sequence of checks, executed in order, with no transport-specific shortcuts.
-
Idempotency key 400
Each order carries a
client_order_id. A caller-supplied value must parse as a UUID; if omitted, the gateway generates one. A supplied id already bound to a live (pending or acknowledged) order is rejected with400. The duplicate check runs under the position-check lock alongside the insert, so two concurrent submits of the same id cannot both pass. The id is the order's handle for status, modify, and cancel. -
Kill-switch & health gate 422 / 503
If the operator kill-switch is engaged (
trade_enabled = false), a new order or a modify is rejected with422; a cancel is exempt, so a resting order can always be pulled. Separately, if any of the exchange, AMQP, or order-book-feed health flags is not green, the order is rejected with503. Neither sends an order into a halted or degraded session.
-
Authentication & permission 403
The caller is authenticated by API token and must hold the
CreateOrderpermission. The check is enforced at the gateway boundary; a caller without it receives403and the order is not processed. -
Request validation & contract resolution 400 / 404
The request fields are validated before any shared state is read: the order-type, execution-restriction, and validity combination, delivery-area length, an iceberg
display_qtybelow itsquantity, avalidity_datepresent when validity is good-till-date, and similar constraints. The target contract is then resolved, either from thecontract_idsupplied or from the(product, delivery_start)pair looked up against the live order book. A malformed field returns400; an unresolved contract returns404. -
Fixed-point units
Price crosses the API as integer cents (
5000= €50.00/MWh) and quantity as integer thousandths of a MW (1000= 1.0 MW). All position, cash, and fill arithmetic runs on these integers. -
Virtual-member resolution, or the global account 400
If the order names a
v_member_short_id, that virtual member must exist, be active, and be assigned to the calling user, unless the caller holdsBypassMemberCheck. With no member named, the order trades the global house account, which requires theTradeGlobalpermission; without it the order is rejected with400.
-
The position-check lock
A single mutex serialises the check-and-insert sequence. This closes the race where two simultaneously submitted orders each read the same pre-trade position, both pass the limit, and together breach it. The second submit blocks until the first is recorded as pending, then re-reads from the updated state. The lock is held from the initial limit read through to the pending insert.
-
Two-sided position limit 422
Exposure is bounded as a two-sided
(max_short, max_long)pair rather than a single net figure, so an oversized buy cannot be masked by a resting sell. A buy widensmax_long, a sell widensmax_short. The order is rejected with422only when its side's bound is pushed past the configured limit and made worse than before, so an order that reduces an already-over-limit side still passes. -
Per-member position limit 422
A member-tagged order is additionally checked against that virtual member's own
max_position, and must clear both it and the account-wide limit. This bounds each virtual member independently under one EPEX membership, and returns422on breach. -
Cash limit 422
Monetary exposure is also bounded: executed trades back to the 16:00-UTC working-day boundary, plus open orders including this one, must stay within the configured cash limit. EUR and GBP are separate pools with no FX conversion, so an order is checked only against its own currency's limit, and a money-making sell contributes zero rather than a negative. A member-tagged order is additionally checked against its member cash limit, which is capped at the global limit. The check runs only when a cash limit is configured, and returns
422on breach. -
Self-trade prevention 422
EPEX M7 does not block self-trades; it only flags them after execution. Voltnir checks before dispatch: if the order would cross one of the account's own resting or in-flight orders on the opposite side, the active policy applies.
observelogs the cross and allows the order;rejectblocks it with aSELF_CROSS_BLOCKEDerror (422). -
The pending insert
Once the limit, cash, and self-trade checks pass and the
client_order_idis confirmed unused, the order is inserted asPENDINGand the lock is released. The pending order is now visible to the next submission's position read, which is what holds the serialisation. The order is tracked under itsclient_order_idbefore M7 has acknowledged it.
-
Publish, then a bounded ack wait
The order is encoded to the M7 wire format and published over AMQP. The call then blocks for a bounded window (2 seconds by default, configurable via
system.order_ack_timeout_ms) for the exchange's first reply. A receipt acknowledgement confirms only that M7 has the message, not that the order is on the book; the order execution report that actually rests the order is a separate exchange message that can arrive later. Whichever lands first within the window decides the state the call returns, and that same blocking call and resulting state are returned on REST, WebSocket, and gRPC alike.- execution report rests it → ACTIVE or HIBERNATED201
- receipt only, not yet rested → PENDING201
- exchange refusal → REJECTED (+ reason)422
- no answer in the window → TIMEOUT504
A submitted order is not static: M7 acknowledges it, rests it, may partially fill it, and finally fills or cancels it. Each order is identified by its client_order_id and carries a revision that increments on every change, so a client follows an order by querying it or by subscribing to the order stream. Placing, reading, modifying, and cancelling are separate, independently granted permissions, and every one of them is confined to the virtual members the caller is assigned to, reads and writes alike, enforced identically on REST, WebSocket, and gRPC.
CreateOrder, plus assignment to the order's member (or TradeGlobal for the house account). REST POST /api/v1/order, WS new_order, gRPC SubmitOrder. Validation chain in §03.ModifyOrder, plus authorization to act on the order's member. REST PUT /api/v1/order, WS modify_order, gRPC ModifyOrder.DeleteOrder, plus authorization to act on the order's member. REST DELETE /api/v1/order (one) and DELETE /api/v1/orders (all), WS delete_order, gRPC CancelOrder and CancelAllOrders.ReadOrders for the firm-wide book, otherwise the caller's assigned members. Visibility only; it confers no right to act. REST GET /api/v1/order (one) and GET /api/v1/orders (list), WS orders stream, gRPC GetOrder, ListOrders, and WatchOrders.Observing changes. An order's revision increments on every state change; fills and cancellations surface as it reaches a terminal state. A client reads the current state three ways: a one-shot query of a single order or the list, the always-on WebSocket orders stream, or the gRPC WatchOrders server-stream, which emits on add, change, and terminal state. The states are PENDING (sent to M7 but not yet rested by an order execution report), ACTIVE (resting and matchable), HIBERNATED (resting but not matching, such as a good-till-cancel order outside session hours), INACTIVE (fully filled or cancelled), and REJECTED. PENDING is a Voltnir-local state, not an M7 one: the order has been accepted by the gateway and published, and M7 may already have acknowledged receipt of the message, but the order stays PENDING until the execution report puts it on the book or a refusal rejects it.
Member isolation, on reads and on writes. By default a caller is confined to the virtual members assigned to it, and the confinement governs both seeing orders and acting on them. A read returns only orders tagged with an assigned member; untagged house-account orders are withheld. A modify or cancel is permitted only when the caller is assigned to the order's own member, with BypassMemberCheck acting across every member and TradeGlobal required for an untagged house order. The write check reads the order's existing member, never a value the caller supplies, so an order cannot be hijacked or re-tagged onto another desk. It is the same authorization that gates order placement, enforced again at every mutation.
Reading is not acting. ReadOrders grants visibility and nothing more: a read-only or compliance account can be given the firm-wide book, every member plus the house account, with no ability to place, modify, or cancel anything in it. The view grant and the act grant are independent and separately auditable, so oversight never implies authority.
A denied action is indistinguishable from a missing one. A modify or cancel the caller is not authorized for returns the same 404 not-found as a genuinely unknown id, never a 403. The mutation surface is therefore not an existence oracle: a desk cannot probe for, or enumerate, orders it may not act on. On the read path a cross-member filter is refused (REST 403, gRPC PermissionDenied, WS PermissionDenied), and the default scope is fail-closed: a session sees no orders until its scope is resolved.
Modifying. A modify targets a live order by id. A price or quantity change can grow exposure, so it re-runs the risk gates under the same position_check_lock as a new order: the two-sided position limit, the per-member limit, and the cash limit, each evaluated against the change in exposure, then the kill-switch and health gate. It runs neither the self-trade nor the idempotency check. A pure activate or deactivate leaves the net position unchanged and skips the risk gates. A second modify of the same order while the first is unconfirmed is refused with 409. The call returns 202 Accepted, and the new revision is confirmed asynchronously on the order stream.
Cancelling. A cancel only reduces exposure, so it runs no limit checks and is exempt from the kill-switch: a resting order can be pulled even while trading is halted. It confirms the order is live and not already mid-modify, refusing with 409 otherwise, then returns 202 Accepted; the order moves to INACTIVE once M7 confirms. Cancel-all respects the same isolation: a caller authorized across the whole account (BypassMemberCheck and TradeGlobal) gets the atomic exchange-side bulk cancel, while a member-scoped caller cancels only the orders it may act on, one per order, leaving other desks' and the house's orders untouched.
Attributable. Every placement, modify, and cancel is checked against the caller's permissions and member assignment at the gateway boundary and recorded with the acting user and virtual member in the audit trail (§09), queryable by user, member, and time.
§03 walks the gates an order clears. This is what those gates measure: a net position you actually hold, and an exposure you could reach, bounded on both sides by a limit you set. P&L runs on the net; the limit checks run on the exposure.
Net versus exposure. Net position is your signed, executed-trade volume on a contract: the megawatts you are actually carrying, and the basis for realized P&L. Only trades in M7's active (ACTI) state count, so a cancelled trade backs out. Exposure adds every resting order, including an iceberg's hidden quantity, as a two-sided (max_short, max_long) pair, the worst case each way if everything resting fills. The position-limit check reads exposure, not net, so a limit is never breached by a fill you could already see coming.
Two-sided, with a carve-out. Each side is bounded independently against the configured limit. An order is refused only when it pushes the bound on the side it grows past the limit and makes it worse; an order that only reduces an over-limit side always passes. A limit of 0 therefore means reductions only, a hard freeze you can still trade out of.
Scoped. Every quantity is tracked per delivery-area/contract, per virtual member, and for the global house account. A member-tagged order must clear both its member's max_position and the account-wide limit.
Cash exposure, in money. Alongside megawatts, monetary exposure is bounded: executed trades back to the 16:00-UTC working-day boundary plus open orders, against a configured cash limit. EUR and GBP are separate pools with no FX between them, so each order is checked only against its own currency; GBP follows ECC's reservation methodology, and a money-making sell floors at zero. A member's cash limit is capped at the global one.
Operator-set, live. The position limit, the cash limit, the self-trade policy, and the trading kill-switch are all set by the operator and switchable at runtime over REST, gRPC, and WS, with no restart. They live in the gateway's profile store rather than static config, so a desk can tighten risk mid-session.
P&L is derived, not stored. On every tick the gateway replays your filled trades and marks your open position against the live order book, producing realized and unrealized figures at four scopes. Each computation starts from scratch, so a restart loses nothing: M7 re-delivers the order snapshot and trade history on reconnect.
Realized: weighted-average cost. Your trades are replayed in execution order through a running position and a weighted-average open price. A trade on the same side as the position adds to that average; a trade on the opposite side closes against it and books closed × (close_price - avg_open_price), signed by whether you were long or short. A partial close leaves the average unchanged on the remainder; a larger opposite trade flips the position and re-opens the residual at the new price. Only trades in M7's active (ACTI) state count. Every trade is an execution; ACTI is its default state, and it drops out of P&L only if M7 cancels or recalls it (CNCL, RGRA, and the related recall and cancellation states). Open and pending orders never contribute, since an order is not an execution.
realized P&L · weighted-average cost · ACTI trades, execution order buy 5 MW @ €100.00/MWh long 5 MW, avg open €100.00/MWh sell 5 MW @ €110.00/MWh closes 5 MW realized = closed × (close − avg_open) = 5 × (€110.00 − €100.00) = €50.00 position → flat long gains when close exceeds avg_open; short inverts. €50.00 = 5,000,000 q8 · hourly contract
Unrealized: marked to the book. The open position is marked at the contract's reference price: the mid of the best bid and ask when both sides of the book hold resting quantity, otherwise the last traded price, otherwise nothing (and unrealized is then zero). Every row reports which of the three produced its mark, so an unmarked position is explained rather than silent. Liveness is read from resting quantity on each side, not the price sign, because power contracts clear at zero and at negative prices and those marks are valid.
unrealized P&L · open position marked to the live book open long 5 MW @ avg €100.00/MWh mark €110.00/MWh mid = (best_bid + best_ask) / 2 unrealized = position × (mark − avg_open) = 5 × (€110.00 − €100.00) = €50.00 €50.00 = 5,000,000 q8 · hourly contract
Scopes and membership. Every figure is produced at four scopes: per contract, per (area, product), per virtual member per contract, and per (member, product). A member's position and realized P&L are computed from only that member's leg of each trade, independent of any other member on the same contract; an untagged trade rolls into the house account but into no member. Access is least-visibility by default: a caller sees only the members assigned to them, and the firm-wide per-contract and per-product books (which aggregate every member plus the house account) are withheld. The read_pnl permission (or bypass_member_check) returns the firm-wide snapshot. A v_member_short_id filter narrows to one member and is refused (REST 403, gRPC PermissionDenied, WS Forbidden) unless the caller is assigned to it or holds broad read; the same scope applies to the always-on P&L stream.
What counts, and the units. P&L counts filled trades only, so the displayed net position is trades-only and agrees with it; the position-limit check in §05 is the one place open orders are counted. On the wire, money is in q8 units (euro × 100,000, so divide by 100,000 for euro), price is €/MWh × 100, and position is MW × 1000. After a contract delists, realized P&L is preserved (the trades remain the source of truth) and unrealized drops to zero.
Every unary operation is exposed on all three transports with identical fields, units, permissions, and error semantics. REST is JSON over HTTP/1.1; the WebSocket feed is one socket carrying both subscriptions and request/response commands; gRPC is protobuf over HTTP/2. Streaming exists only on WebSocket and gRPC.
| REST:3000 | WS:9001 | gRPC:3443 | |
|---|---|---|---|
| orders submit / modify / cancel / query | ✓ /api/v1 | ✓ command | ✓ unary RPC |
| contracts & market reads | ✓ | ✓ | ✓ |
| system read / write limits, kill-switch, self-trade policy | ✓ | ✓ | ✓ |
| users & members | ✓ | ✓ | ✓ |
| streaming book, orders, trades, P&L, public | none | ✓ subscribe | ✓ Watch* |
unary operations are identical across all three transports; streaming has no REST equivalent.
Shared implementation. Each unary operation runs one code path regardless of transport, so fields, units, permissions, and error semantics do not diverge between REST, WebSocket, and gRPC. The three surfaces are asserted against each other at build time so they stay in step.
Versioning. REST is served under /api/v1; the WebSocket handshake accepts /ws/v1 (with / and /v1 as transition aliases, unknown versions rejected at upgrade); the gRPC service is voltnir.api.v1. The protocol version is echoed in the WebSocket config payload. Incompatible changes are published under a new version path; v1 is not mutated in place.
Transports. REST: JSON request/response on port 3000, usable from curl. WebSocket: port 9001, authenticate with {action:'auth', token} after connect, then issue subscriptions and commands on the same socket; stream frames are zstd-compressed binary, command responses are plain-text {type:'response', req_id, op, ok, result|error}. gRPC: service voltnir.api.v1 over HTTP/2 on port 3443, with an in-tree Python SDK generated from the service .proto.
Streaming. On WebSocket, the account streams (orders, trades, messages, status, P&L) are active once authenticated; contracts stream per delivery-area and product; public trades, the trade tape, and the hub-to-hub matrix are opt-in subscriptions. gRPC exposes the same data as server-streaming Watch* RPCs. There is no REST streaming surface. Market-data streams are covered in §02.
Encoding. Prices, quantities, and limits cross the wire as fixed-point integers: price in cents, quantity in thousandths of a MW. There are no floats in the API. API keys are SHA-256 hashed at rest and returned once at creation; the raw key is not stored and cannot be recovered.
One EPEX membership can host many virtual members, each with a short id, an active flag, and its own max_position. Users are assigned to members and hold a set of permissions, checked at the boundary on every action. Twelve permissions compose the access model:
A user with CreateOrder but not DeleteOrder can place orders and not cancel them: not their own, not anyone's. Trading entitlements (CreateOrder / ModifyOrder / DeleteOrder) are separate from operational ones (ToggleTrading, SetPositionLimit, RestartSystem), from read entitlements (ReadAudit for the audit log, ReadPnl for the firm-wide P&L), and from administrative ones (ManageUsers, ManageMembers). BypassMemberCheck is the deliberate exception that lets a privileged user act without virtual-member binding, and since it already lifts member restriction it also confers the firm-wide P&L read.
Attribution travels with every action: which user, which virtual member, which EPEX user_id, recovered even on the exchange acknowledgement because the member tag rides in the M7 free-text field.
Orders and trades are persisted to your chosen backend and queryable through the REST audit endpoints: filter by user, member, event and time range; export as CSV or JSON. The public trade tape can optionally be persisted on PostgreSQL and queried the same way, with a retention window you set.
limit defaults to 50, caps at 200; an explicit limit=0 is a 400, not a full-table scan.time_basis=delivery (default) filters by delivery overlap; time_basis=execution filters by when the action happened.ExportReports permission.Voltnir ships a React trading terminal built on the exact API any client uses: nothing privileged, nothing hidden. The binary can serve it directly on port 8080, or you can deploy it as a static SPA.
Chart & studies
Candlestick chart with order-flow and technical studies: VWAP, volume profile, point of control, cumulative delta, TWAP, opening range, absorption and divergence.
Trade & monitor
L1–L5 depth ladder, the public trade tape, order entry with pre-flight cash / position / throttle checks, live positions, audit, and P&L drill-down.
Admin & ergonomics
Members and users administration, the XBID hub-to-hub matrix, and eight built-in themes (plus one hidden) with adjustable density.
The customer portal is where you manage your account and self-serve everything Voltnir delivers outside the binary.
.proto contract for generating your own clients.Everything that keeps the gateway honest in production, and keeps you in control of it.
observe (flag) or reject (block before M7), switchable at runtime. EPEX doesn't prevent self-trades; Voltnir does.Voltnir is free on the EPEX SPOT simulator. One static binary, one signed licence file, one outbound connection. Minutes to deploy. Bring a Linux server and your EPEX SPOT credentials; we bring everything else.