01 · OVERVIEW
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.
02 · SCOPE & BOUNDARIES
The fastest way to tell whether Voltnir fits: here is exactly what it connects to and trades, and what it deliberately does not do. If your market or your workflow is on the wrong side of a line below, you will know in a minute rather than a pilot.
what Voltnir connects to and trades exchange EPEX SPOT, trading system M7 market intraday continuous (the live order book) delivery the EIC delivery areas you are admitted to products the M7 products you enable (hourly, half-hourly, quarter-hourly) cross-border XBID hub-to-hub capacity, optional (§05) access one outbound connection to M7 (§03) delivery areas and products are yours to configure; Voltnir trades only what your EPEX SPOT membership is entitled to.
One market, configured to yours. Voltnir trades the EPEX SPOT intraday continuous market on the M7 trading system, and nothing else. The delivery areas (by EIC code) and the M7 products are not hard-coded: you list the areas your EPEX SPOT membership is admitted to and the products you want, and Voltnir maintains the order book, validates orders, and tracks position, cash, and P&L for exactly that set. Contract granularity follows M7, hourly through quarter-hourly; the full order-type set, including block and balance orders, is in §06. The connection and its TLS are §03; the order book it maintains is §04.
What Voltnir is not. Knowing the boundaries is half the fit decision.
03 · EPEX SPOT M7 CONNECTIVITY & TLS
How Voltnir reaches the exchange: two M7 channels over one mutual-TLS identity, endpoints chosen by environment rather than typed in, and a trust store built to work on any host. The connection is outbound only, so you open nothing inbound toward EPEX SPOT.
outbound to EPEX SPOT M7 · one mTLS identity · you dial out AMQP (M7 v6) order management: submit / modify / cancel, plus acks WS (M7 v7) live streams: public order book + private session (your resting orders and their execution reports) identity one mTLS client certificate + key, shared by both login AMQP user + static password + a fresh TOTP per connect endpoints fixed per environment; you select sim or prod trust bundled CA roots + OS store + optional extra_ca_path primary and alternate hosts per environment; both transports reconnect themselves with exponential backoff (§24).
Two protocols, one identity. Order management, submit, modify, cancel, and the exchange's acknowledgements, runs over AMQP (M7's v6 interface). Live data runs over WebSocket (M7's v7 interface) as two persistent streams: the public order book (snapshots and incremental deltas for every product and delivery area you trade) and a private session stream (your own resting orders and their execution reports). Both authenticate with the same mTLS client certificate and key. The link is outbound only, Voltnir dials EPEX SPOT and EPEX SPOT never dials in, so the gateway needs no inbound opening toward the exchange. The order book these streams build is §04; the order path that rides AMQP is §07.
Endpoints follow the environment, not your config. You do not enter hostnames. Set the environment to sim or prod and the M7 AMQP and WebSocket endpoints (host, port, virtual host) are taken from fixed per-environment constants, each with a primary and an alternate host the reconnect supervisor fails over to. The environment must be one your licence permits (§15), so a sim install can never reach production.
Authentication is cert plus credential plus a rolling code. Beyond the client certificate, the AMQP session logs in with your M7 user, a static password, and a time-based one-time code (TOTP) generated fresh at every connect from the secret you hold. Three factors, none of them replayable on their own.
Portable trust. The server-trust store is layered so the single binary validates M7 on any host. First, a set of CA roots compiled into the binary, so it works even on a minimal or container host with no system ca-certificates (Distroless, scratch, bare Alpine). Then the host's own OS trust store on top, extending rather than replacing it. Then an optional operator bundle, connection.extra_ca_path, for a corporate TLS-inspection proxy or a private intermediate CA in the M7 chain. A misconfigured extra-CA path fails loudly on purpose: a path that cannot be read stops startup, while a readable file with no certificates in it warns and continues on the bundled and native roots.
It reconnects itself. Both transports recover without intervention: AMQP reconnects with exponential backoff behind a short heartbeat, WebSocket with exponential backoff and ping/pong health tracking, each failing over to the alternate host. What a degraded or dropped feed means for trading, orders are gated until the book is healthy again, is covered in §24.
04 · MARKET DATA
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 and the public trade tape 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. The upstream connection itself is §03; cross-border transfer capacity is its own feed, §05.
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.
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.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. Both the live trade tape and the raw order-book frames can be persisted for history, replay, and backtesting, off by default and configured under market_data. Trade-tape capture is §18; raw order-book capture is §19.
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 (§07), so an order is never sent against a stale or torn book.
05 · CROSS-BORDER & HUB-TO-HUB
Trading one bidding zone against another is only real if power can actually cross the border. EPEX SPOT's continuous intraday market is coupled across zones through XBID, and the binding constraint is the available transfer capacity (ATC) on each border. Voltnir relays M7's hub-to-hub ATC feed and keeps it as a live matrix, so a price spread can be read against the capacity that would have to carry it.
hub-to-hub available transfer capacity (ATC) · in MW per delivery interval, for each ordered pair of bidding zones: out room to flow FROM the source zone to the target in room to flow INTO the source zone from the target DE → FR out 1200 in 800 revision 47 · 14:32:10Z DE → BE out 900 in 650 (older revisions ignored) source: XBID, relayed by M7 (opt-in); each interval expires with its delivery window and is purged after a grace period.
A live capacity matrix. When enabled, Voltnir consumes M7's hub-to-hub feed and maintains, for every ordered pair of delivery areas and every delivery interval, the import and export capacity in MW. The direction follows the exchange's own convention: from a source zone's point of view, out is the capacity for flow from it to the target and in is the capacity for flow into it from the target. It is off by default, opt-in per deployment, because not every desk trades cross-border. Each entry is also enriched with the best bid and ask in both the source and destination zones for that delivery window, so the cross-zone spread reads straight from the same feed rather than being stitched together client-side.
Revisioned and time-bounded. Each interval's values carry a revision number, so a late or out-of-order update can never overwrite a newer one. Entries are scoped to their delivery window: once an interval has ended they are tombstoned and purged after a grace period, so the matrix only ever holds capacity that still matters.
Connection health is part of the data. M7 watches its own link to XBID and signals when it drops; Voltnir surfaces that as a capacity-connected flag served alongside the matrix, and triggers a resync when it detects a gap in the feed. A stale or missing matrix is therefore visible as such, never silently presented as current.
How you reach it. The ATC matrix is queryable on REST, WebSocket, and gRPC for a delivery area and time window, each response carrying the enabled and capacity-connected flags. The live subscription, watch_hub2hub, is WebSocket-only: it is the one streaming feed with no gRPC Watch mirror, the documented exception to the streaming parity in §21.
The opportunity map. The trading terminal (§26) turns this matrix into a pan-and-zoom map of Europe. Each bidding zone is coloured not by raw capacity but by trading opportunity: the cross-zone price spread gated by the capacity available to carry it, so a wide spread you could not actually move power across is not lit up as something to chase. Italian sub-zones fold onto a single fill and borders resolve by decoding their area (EIC) codes. The single-zone order book this complements is §04.
06 · ORDER TYPES
An order is more than a side, a price, and a size. Voltnir exposes M7's order-entry model in full: alongside those three, every order carries a type, an execution restriction, a validity, and an entry state. The four are validated as one combination before anything is published (the pipeline in §07), with the same fields and the same rules on REST, WebSocket, and gRPC.
an order entry · four axes, validated as one combination (§07) side BUY price €48.50/MWh integer cents on the wire quantity 5.0 MW integer sub-MW on the wire type iceberg peak shown, the rest in reserve restriction none or FOK · IOC · AON validity good-for-session or good-till-date · none entry state active or hibernated (held off the book) refused before dispatch (400, never reaches M7): iceberg without a display peak below its size · pre-arranged without a counterpart · good-till-date without a date · fill-or-kill / immediate-or-cancel with any validity but none
Five entry types. A regular limit order is the default. An iceberg shows only a peak on the book and holds the rest in reserve: its display_qty is the visible slice and must be strictly smaller than the total quantity. A pre-arranged order books a bilaterally agreed trade and carries its counterpart account; it is input-only, and once accepted it reads back as the exchange's pre-arranged type. M7's block and balance types are accepted and forwarded as well. Order-book-only types the exchange publishes but does not accept on entry, such as stop and private orders, are rejected at submission.
How much fills, and when. The execution restriction governs the fill: the default rests a limit order on the book, fill-or-kill executes in full immediately or not at all, immediate-or-cancel takes whatever is available now and cancels the remainder, and all-or-none refuses any partial fill. Validity governs the lifetime: good-for-session (the default), good-till-date (which requires an explicit validity_date), or no explicit validity. Because a fill-or-kill or immediate-or-cancel order resolves at once, it must pair with no-validity; any other pairing is refused.
Entered live, or dormant. An order is entered either active, exposed to the market straight away, or hibernated, accepted by the exchange but held off the book. A hibernated order is staged risk you release in one step: activate it to expose it, deactivate a live one to pull it back, both as runtime actions on the existing order rather than a new submission. The lifecycle states this produces (including HIBERNATED) are covered in §08.
Validated as a whole, before the wire. The combination is checked in the submission pipeline (§07): an iceberg needs a display peak below its size, a pre-arranged order needs its counterpart, a good-till-date order needs its date, fill-or-kill and immediate-or-cancel need no-validity, the delivery area is required, and the quantity must be positive. A bad combination returns a 400 and never reaches M7. On the wire, price is integer cents and quantity integer sub-MW (1000 = 1.0 MW); there are no floats.
07 · ORDER SUBMISSION
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 SPOT membership, and returns422on breach. -
Cash limit 422
Monetary exposure is also bounded: executed trades back to the 16:00 CET working-day boundary (ECC clearing time), 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 SPOT 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, and the call returns the moment the exchange's first reply lands. In normal operation that acknowledgement comes back almost immediately; the bounded window is a safety ceiling, not a delay every order waits out. It only caps how long the call may block (2 seconds by default, configurable via
system.order_ack_timeout_ms) so a request returns rather than hang indefinitely if the exchange stops responding. 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 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
08 · ORDER LIFECYCLE
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 §07.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 200 OK, 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 200 OK; 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 (§16), queryable by user, member, and time.
09 · POSITION AND EXPOSURE
§07 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 and operator limits have their own sections. Monetary (EUR/GBP) exposure limits are covered in §11, and the runtime risk controls (the trading kill-switch, live limit changes, the self-trade policy) in §13.
10 · P&L CALCULATION
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 §09 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.
11 · CASH LIMITS
Where §09 bounds megawatts, this bounds money. Voltnir's cash limit is built to ECC Risk Management Services, the European Commodity Clearing risk framework for EPEX SPOT: it implements the financial trading-limit methodology for EPEX SPOT continuous trading, §3.11 in EUR and §3.12 in GBP, as a pre-trade check every order clears before it reaches M7 (the gate in §07).
cash exposure · per currency · reset on ECC's clearing schedule exposure = open orders (reserved) + executed trades since the last reset reset = 16:00 CET on ECC Business Days spans weekends + holidays · Fri 16:00 → Mon 16:00 EUR pool GBP pool buy consumes price × MWh buy consumes price × MWh sell at +price → 0 (risk-free) sell reserves a per-MWh rate open order never credits (ECC delivery risk, never credits) separate pools, no FX · an order draws on its own currency only · a breach returns 422, before the order reaches M7
Built to ECC Risk Management Services. ECC's framework defines the cash limit as the maximum financial exposure a participant may carry between two clearing booking cuts. Voltnir implements that methodology as a front-end pre-trade control: it mirrors the binding limit ECC applies exchange-side and refuses an order whose exposure would breach it before the order is ever sent. The §3.11 and §3.12 rules, the reset window, the risk-free carve-out, and the GBP delivery-risk reservation, are implemented as written; you configure the engine with the figures ECC publishes for your account (the limit value, the GBP reservation rate, the GB delivery areas, and the business-day calendar). A limit of zero leaves the pre-trade check off, ECC's own limit still binds, so set a positive value to arm it.
What consumes the limit. Exposure is the financial volume of your open orders plus your executed trades, the same set the position check in §09 reads. An open order reserves price × energy and only ever reduces headroom: a money-making order, an EUR sell at a positive price or a buy at a negative price, is risk-free under ECC and contributes zero, never a credit. A filled trade then moves headroom by its full signed value, an EUR sell credits and a buy consumes. A partial fill is not double-counted: the filled part is a trade and only the remainder still rests as an order. Energy is price × MW × hours, so a quarter-hour contract counts a quarter of the hourly value.
EUR and GBP are separate pools. ECC settles the two currencies independently, so Voltnir holds two pools with no FX between them and checks an order only against its own currency's limit. The EUR limit covers every M7 delivery area except the GB market; Great Britain (10YGB----------A) is recognised automatically and carries its own GBP limit, so no desk has to flag it. The Irish SEM market settles in EUR, not GBP, including Northern Ireland (10Y1001A1001A016) and the Republic of Ireland (10YIE-1001A00010), even though Northern Ireland is politically part of the UK. GBP follows ECC's delivery-risk methodology, where a sell does not credit but reserves a per-MWh rate that ECC revises on a published schedule.
The reset, and what it spans. Exposure is measured between two ECC booking cuts: 16:00 in ECC's clearing timezone (Europe/Amsterdam, CET/CEST, so the boundary tracks daylight saving) on ECC Business Days. The window resets only on business days, so a Friday window runs through to Monday and bank holidays extend it further, exactly as ECC re-bases exposure. Executed-trade value older than the current window rolls off; open orders are never windowed, since a resting order is a live commitment until it fills or is cancelled. The reset timezone and the holiday calendar are configured to match ECC's. Consumed exposure is reconstructed for the current window when the gateway starts, so a restart mid-window does not reset the count; the limit stays accurate across restarts.
Global, and per member. The limit applies to the whole account, and a virtual member (§14) can carry its own tighter sub-limit, always capped at the global one, so a member is never granted more headroom than the desk holds. A member-tagged order must clear both, on its own currency's pool. The members API reports each member's live usage alongside its configured limit, how much of its cap is consumed and how much headroom remains, per currency pool, so a desk reads a member's spend against its limit rather than just the figure it set. The effective limit shown is the member's own value capped at the global, the same value the pre-trade check enforces.
12 · SELF-TRADE PREVENTION
EPEX SPOT M7 reports a self-trade only after it executes, by flagging the print. Voltnir adds a pre-trade check so a desk can stop its own orders crossing each other before the order is sent. It runs as a gate in the submission pipeline (§07), under the same position-check lock, after the position and cash checks and before the order reaches M7.
self-cross check · own opposite-side order at a crossing price trigger same contract & delivery area · opposite side · price crosses · and it is one of YOUR OWN orders scope resting (M7-confirmed) AND in-flight (pending) orders cross new BUY @ p crosses a resting SELL @ s when p ≥ s new SELL @ p crosses a resting BUY @ b when p ≤ b observe log and allow (default) reject block with SELF_CROSS_BLOCKED 422 price compared as integer cents; zero and negative prices are valid marks, so this is a true cross test, not a sign check.
What counts as a self-cross. An incoming order crosses your own book when one of your orders sits on the same contract and delivery area, the opposite side, at a price the incoming order would trade through. Both your resting orders (acknowledged by M7) and your in-flight orders (submitted, not yet acknowledged) are checked, so a back-to-back resting-and-aggressor pair cannot slip through the gap before an ack. Quantity is not part of the test: any own opposite-side order at a crossing price triggers it, whatever its size.
Two policies, switched at runtime. observe, the default, logs the cross and lets the order through; the exchange's own selfTrade flag still lands on the resulting print. reject blocks the order before it reaches M7, returns SELF_CROSS_BLOCKED (REST 422, gRPC FAILED_PRECONDITION, the same code on WebSocket), and records a system message. The policy is read and set over REST, WebSocket, and gRPC under the SetSelfTradePolicy permission, held in the runtime profile store so a change survives a restart, and every change is written to the audit trail with the actor and the before and after value (§13, §16).
New orders only. The check runs on order entry. A modify that reprices a live order does not re-run it (§08), so a cross introduced by amending an existing order is not caught even under reject.
13 · OPERATOR CONTROLS & KILL-SWITCHES
The risk knobs the desk turns by hand. Each is changed live over REST, WebSocket, or gRPC with no restart, is persisted so it survives one, is gated by its own permission, and lands in the audit trail with who changed it. This is the surface; the §09, §11, and §12 sections cover what each one bounds.
operator controls · live over REST · WS · gRPC · no restart kill-switch toggle_trading off → halt + flatten the book position limit set_position_limit per-contract MW bound (§09) cash limit set_cash_limit EUR / GBP money bound (§11) self-trade set_self_trade_policy observe or reject (§12) restart restart_system graceful process restart every change is persisted (survives a restart) and written to the audit trail with the actor and the before → after value (§16)
The kill-switch. The headline control does two things in one move. Turning trading off refuses every new order and modify with a 422, and it immediately sends a cancel-all to M7 so the resting book is flattened on the exchange, not merely locked locally. Cancels are never gated, so you can keep pulling orders while trading is off, reducing exposure is always allowed. For a hard stop, a config flag (system.disable_trading) forces trading off and cannot be re-enabled at runtime: a deployment can ship locked down, and an attempt to toggle it back on is refused with a 409.
Live, persisted, attributed. Every control here lives in the gateway's profile store, not in static config. A change takes effect immediately on REST, WebSocket, and gRPC alike with no restart; it is written through to disk so it survives one; and it is recorded in the audit trail (§16) with the acting user and the before and after value. Tuning that genuinely needs a restart (timeouts, ports) stays in the config file, kept separate from the live knobs on purpose.
Granted independently. Each control is gated by its own permission, shown above (the access model is §14). So the kill-switch can be handed to a risk desk without the power to move limits, a limit can be retuned without the right to halt trading, and a restart stays with operations, each grant standing on its own.
14 · USERS, MEMBERS, AND PERMISSIONS
Every limit in §09 and every P&L figure in §10 was computed for an actor this page had not yet named. This is that actor: the user who authenticates, the virtual members they trade on behalf of, the house account behind them all, and the permission set checked at the gateway boundary on every action.
one EPEX SPOT membership · many virtual members · one house account EPEX SPOT membership ├─ house account untagged orders · needs TradeGlobal ├─ VM001 active max_position · cash_limit (≤ global) ├─ VM002 active max_position · cash_limit (≤ global) └─ VM003 inactive rejects new orders U001 ─ assigned ▶ VM001 VM002 U002 ─ assigned ▶ VM003 every order, trade, and account change is attributed to the (user, member) pair behind it, queryable by either (§16).
Users authenticate with a token, nothing else. The API key is shown once at creation and never stored; only its SHA-256 hash is kept, and a request is resolved by hashing the presented token and matching the hash. Every user also has a stable short id (U001, U002, …) that keeps every order and trade they place attributable to them for the life of the audit trail. Keys rotate in place; the user, its short id, and its history are untouched.
Virtual members are the sub-accounts you actually trade through. One EPEX SPOT membership hosts many. Each carries a short id, an active flag, a max_position in MW, and its own cash limit; an inactive member rejects new orders, and a per-member cash limit is always clamped to the global one, so a member can never be handed more headroom than the desk itself holds. An order that names no member trades the global house account directly. The limit and P&L arithmetic of §09 and §10 is tracked at every scope: per member, for the house account, and account-wide.
Two rules bind users to members. At order time, the gate §07 walks, a user may act only on a member assigned to them, unless they hold BypassMemberCheck; trading the house account directly requires TradeGlobal. At read time the default is least-visibility: a caller sees only the orders and P&L of the members assigned to them. ReadOrders and ReadPnl each widen that to the firm-wide book (every member plus the house account), and BypassMemberCheck, since it already removes member binding, confers the firm-wide read as well.
A granular permission system composes the access model, each permission checked at the gateway boundary on every transport with identical semantics. They group by what they touch:
CreateOrder, ModifyOrder, DeleteOrder. Place, amend, and cancel orders, each granted independently, so a user can place orders without being able to cancel any, their own included.ToggleTrading, SetPositionLimit, SetCashLimit, SetSelfTradePolicy. The operator knobs from §09: the kill-switch (which also cancels every open order), the position and cash limits, and the self-trade policy.ReadOrders and ReadPnl widen the order and P&L view to firm-wide. ReadAudit queries the who-did-what trail and ReadM7Errors the exchange-fault log, kept as separate grants so one can be given without the other. ExportReports downloads CSV or JSON.ManageUsers and ManageMembers create and configure users (with their keys) and virtual members. These two are the Desk tier; see below.RestartSystem triggers a graceful restart. BypassMemberCheck acts without member binding. TradeGlobal trades the house account.Trader and Desk tiers differ in one place. The permission set is identical on every license; the tier decides only whether the two administrative permissions can be used at all. A Trader license has no management surface: ManageUsers and ManageMembers operations are refused at the boundary on REST, WebSocket, and gRPC alike (REST returns 403 LICENSE_DESK_REQUIRED), before the caller's own permissions are even consulted. A Desk license opens that surface. Everything else, trading, risk control, the reads, the house account, is identical across both.
Every action is attributable. Each order, trade, and account change in the audit trail (§16) carries the user and the member that produced it, queryable by either.
15 · LICENSING & ENFORCEMENT
Every install runs against a signed licence the binary verifies at startup, offline, before any subsystem starts. The licence fixes the environment it may reach, the EPEX SPOT identity it may trade as, the tier it runs at (§14), and the date it expires. There is no licence server to call and nothing phones home.
signed licence · Ed25519 · verified at startup, offline environment sim · prod · sim_and_prod a sim licence can't reach prod mode trader · desk desk unlocks user/member mgmt epex identity account_id + user_id must match the deployment validity issued_at → expires_at 14-day grace, then shutdown a bad, mismatched, or post-grace licence stops the process at startup; a runtime monitor re-checks hourly and exits on expiry.
Signed, verified locally. A licence is a JSON payload with an Ed25519 signature over its canonical form; the verifying public key is compiled into the binary, so verification needs no network and no external key file. It is strict and runs before anything else starts: a missing or malformed licence, a wrong issuer, an unknown signing key, a broken signature, an identity or environment mismatch, or a licence past its grace window all stop the process at startup rather than degrade it. Nothing runs on an unverified licence.
Bound to an environment and an EPEX SPOT identity. The licence names the environment it permits, sim, prod, or sim_and_prod, and that is checked against the gateway's configured environment at startup: a sim licence can never reach production. A production-capable licence must enumerate the concrete EPEX SPOT identities (an account_id and user_id per identity) it is issued for, and the deployment's configured identity must match one of them; a plain sim licence is identity-unrestricted. The licence carries no delivery areas or currencies, those follow your EPEX SPOT membership and your config, not the licence.
Two tiers, one gate. The mode field is Trader or Desk. They differ only in whether user, member, and permission management can be used at all (§14): on a Trader licence those operations are refused at the gateway boundary on REST, WebSocket, and gRPC (REST 403 LICENSE_DESK_REQUIRED) before the caller's own permissions are even consulted. Trading, risk control, the reads, the house account, and the full REST, WebSocket, and gRPC surface are identical on both tiers.
Expiry is surfaced, then enforced. Approaching expiry is announced, not sprung: a notice is logged, pushed to the live message stream, and written to the audit trail as the remaining time crosses 30, 14, 7, and 1 days. After the end date a 14-day grace period begins, during which the gateway keeps trading. Once grace ends the licence is fatal: the startup check refuses to boot, and a runtime monitor that re-evaluates the licence every hour logs out of M7 cleanly and exits the process. Renewing is a new licence file and a restart.
16 · AUDIT
§14 named the actors. This is the ledger of what they do. Every action that changes state is written once to an append-only trail, stamped with who did it, from where, and the exact before and after of what changed. Orders, trades, and every fault the exchange returns are recorded the same way. All of it is queryable and exportable on every transport, retained for exactly as long as you choose, and none of it is your strategy.
audit event · one append-only row per actor-driven mutation ts 2026-06-21T14:03:11Z actor U001 e.vandewal who transport rest how it arrived source_ip 203.0.113.7 from where action permissions_set what target user U002 on what before ["read_audit"] ┐ the exact after ["read_audit", ┘ change "manage_users"] outcome ok appended, never updated · keyset paginated on (ts, id) · retention you set (0 = keep forever)
The who-did-what trail. Every mutation that changes state appends exactly one row, and no row is ever updated after it is written. The vocabulary is closed and tied to the permission model of §14: user create and delete, permission grants, member create and modify, position- and cash-limit changes, the trading kill-switch, the self-trade policy, cancel-all, order rejections, report exports, and the license and system-lifecycle events, those last under a system actor rather than a person. Each row carries the actor (stable id, short id, and the username as it read at the time), the transport it arrived on (rest, ws, or grpc), the source IP, a before and an after snapshot of what changed, an outcome, and a reason when it failed. Recording is decoupled from the action: events are enqueued and written in batches by a background writer, so the trail never blocks the order or the mutation that produced it.
Orders and trades, attributable end to end. Alongside the event trail, every order and every trade is persisted and queryable, each stamped with the Voltnir user and the virtual member behind it (the attribution of §14) as well as the EPEX SPOT trader code. Filter by user, by member, by area or product, and by time; trades filter on either the delivery window or the execution time, your choice per query.
The exchange tells its own story, separately. Every fault M7 returns is captured off the AMQP line into its own log, behind its own permission (read_m7_errors, deliberately distinct from read_audit): business error responses enriched from the exchange's DFS200 catalog with a human identifier, a category, and the raw numeric code, plus the quieter drop-points, parse errors, uncorrelated acknowledgements, and sequence gaps. An exchange problem becomes evidence with a timestamp, not a line that scrolled past. The split lets you hand the fault feed to operations without opening the who-did-what trail, and the reverse.
read_audit; the exchange-error log on read_m7_errors.(timestamp, row id) behind an opaque cursor, stable under concurrent writes. limit defaults to 50 and caps at 200; an explicit limit=0 is a 400, never a full-table scan. The first page carries a total hint.ExportReports. The export is itself audited, so the trail records who pulled which window, and when.0 means keep forever. Nothing is deleted inside the window, nothing lingers past it.What never leaves your box. The audit log records actions, not intent: your strategy, your model weights, your forecasts, and your P&L logic are never stored and never transmitted. The compliance trail is append-only with a retention window you set: not a black box you have to trust, a ledger you can read.
17 · PERSISTENCE & STORAGE BACKENDS
The audit trail (§16), the orders and trades, the operator profile, and the user and member records all live in one store. You choose what that store is, and the two choices are held at exact parity.
Two backends, your choice. database.backend selects an embedded SQLite file (the default, ./voltnir.db, zero external dependencies) or an external PostgreSQL server (a url, or discrete host/port/user/password/dbname). The same schema and the same code paths run on either; nothing about the API or the data model changes with the backend. Both drivers are in the one binary (§25), so switching stores is a config change, not a rebuild.
Held at parity by test. Every query runs against both backends in a dual-backend test battery, an in-memory SQLite and a PostgreSQL 16 container, asserting identical results. A query that diverges fails the build, so the two stores cannot drift apart. This is the storage-layer counterpart to the three-transport parity of §20.
Retention is per log. The compliance trail and the M7-error log each carry their own day-window retention (default 7 days, 0 = keep forever), pruned hourly (§16). Market-data capture is a separate, heavier storage story with its own backends and retention, §18 and §19.
18 · MARKET-DATA CAPTURE
The market-wide public trade tape (§04) can be persisted for history and analysis. It is off by default, opt-in under market_data.public_trades, and writes to one of two stores.
public-trade tape capture · market_data.public_trades persist: postgresql into the main audit DB · queryable on the audit API · needs database.backend: postgresql persist: parquet rotating export files · any audit backend (SQLite included) · export-only, not queryable retention_days default 7 · 0 = keep forever pruning whole daily partitions / whole files only default off; fire-and-forget over a bounded channel, so capture never blocks the live tape.
Two stores. With persist: postgresql the tape is written into and queried from the main audit database, reachable through the same audit query and export surface as orders and trades (§16); this requires the audit backend itself to be PostgreSQL, and the gateway refuses to start otherwise. With persist: parquet it is written to rotating Parquet files for export only, never read back through the API, and works on any audit backend including SQLite.
Retention and safety. retention_days defaults to 7 (0 = keep forever); pruning drops whole daily partitions (Postgres) or whole files (Parquet), never row by row. Capture runs off a bounded channel batched by a background writer, so under overload it drops rows and logs rather than stall the live tape.
19 · ORDER BOOK CAPTURE
Every raw M7 order-book frame, the snapshots, the deltas, and the synchronisation markers between them, can be archived for replay and backtesting. It is off by default, opt-in under market_data.order_book, and is never served back through the API: it is capture-only material.
raw order-book capture · market_data.order_book persist: postgresql daily range-partitioned table · own or the audit DB's inherited PostgreSQL connection persist: parquet rotating files · new file every rotation_secs (3600) or every max_rows_per_file (1,000,000) retention_days default 0 = keep everything exposure none · replay / backtest only, never on the API default off; like the tape, fire-and-forget over a bounded channel that drops under overload rather than touch the feed.
The wire feed, not the maintained book. What is captured is the frames exactly as M7 publishes them, each stamped with a receive time for ordered replay. This is not the in-memory book of §04; it is the raw feed itself, kept so you can reconstruct the market offline. It is never exposed on REST, WebSocket, or gRPC.
Where it goes, and how long it stays. persist: postgresql writes a daily range-partitioned table on its own PostgreSQL connection, or the audit database's if that is already Postgres; persist: parquet writes rotating files, a new file every rotation_secs (default 3600) or every max_rows_per_file rows (default 1,000,000). retention_days defaults to 0, keep everything, since a captured book is reconstruction material you usually do not want thinned; when set, pruning drops whole daily partitions or whole files. As with the tape, capture is fire-and-forget over a bounded channel and drops under overload rather than block the feed.
20 · TRANSPORT PARITY
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 | ✓ | ✓ | ✓ |
| state & status reads session health, trading posture & risk limits | ✓ /state · /status | ✓ get_state · get_status | ✓ GetState · GetStatus |
| 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 on every release build 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. Streaming is covered in §21; market-data streams in §04.
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.
21 · STREAMING & LIVE DATA
Live data is delivered two ways that stay in step: WebSocket subscriptions and gRPC Watch* server-streams. REST has no streaming surface (§20). Every live data type exists on both transports, a WS subscription and a gRPC Watch RPC ship together, save for two documented WebSocket-only feeds.
live data type · WS subscription ↔ gRPC Watch* RPC contracts WatchContract per area / product orders WatchOrder · WatchOrders trades WatchTrades public_trades WatchPublicTrades opt-in pnl WatchPnl state WatchState status WatchStatus 1 Hz aggregate frame messages WatchMessages audit WatchAuditEvents opt-in · read_audit m7_errors WatchM7Errors opt-in · read_m7_errors trade_tape (enriched tape · WebSocket only, no gRPC mirror) watch_hub2hub (XBID capacity · WebSocket only, no gRPC mirror) WS stream frames are zstd-compressed binary; the matched gRPC Watch RPC carries the same data, units, and permissions.
Two interaction styles on one WebSocket. After {action:'auth', token} the socket carries both subscriptions and request/response commands (§20). Subscription frames are zstd-compressed binary, one stream per frame; command responses are plain text. The account and session streams, orders, trades, messages, pnl, state, and the status aggregate, are available throughout the session; contracts is subscribed per delivery area and product; public_trades, trade_tape, audit, and m7_errors are opt-in.
Matched on gRPC. Each live type has a Watch* server-stream carrying the same data, units, and permissions as its WS subscription, so a client can consume the same feeds over HTTP/2 protobuf. The mapping is fixed and asserted on every release build.
The aggregate that is now mirrored. The status frame is an aggregate, the M7 throttling state, the trading and operational flags, the position and cash limits with live consumption and headroom, and the licence in one 1 Hz frame. It is no longer WebSocket-only: it is now mirrored by the gRPC WatchStatus server-stream and backed by the unary GetStatus RPC and GET /api/v1/status (§20), carrying the same data, units, and permissions.
The two WebSocket-only feeds. Two live feeds remain WebSocket-only, with no gRPC Watch mirror. The enriched trade_tape (§04) carries each print pre-joined with its contract metadata, a WebSocket-native convenience over the public-trade feed. The watch_hub2hub capacity stream (§05) is the second: gRPC and REST expose cross-border capacity only as a one-shot fetch.
Event-driven, permission-gated tails. The audit and m7_errors streams are gated server-side by read_audit and read_m7_errors, the same permissions as their unary queries. They are event-driven: a row is broadcast the instant its insert commits, so a desk running many terminals tailing the trail costs no extra database queries, seed history from the query, then follow (§16).
22 · API DOCS, SDK & CLIENTS
The wire surface is documented and generatable. Five reference docs cover REST, gRPC, WebSocket, the config file, and deployment; a first-party Python SDK and the service .proto let you talk to the gateway without reverse-engineering a frame.
The reference docs. Five HTML references ship with the gateway and are the contract for each surface: REST v1, gRPC v1, WebSocket v1, the full config.yml reference, and the deployment-and-TLS guide. The customer portal serves all five behind login.
The Python SDK. A first-party Python SDK (voltnir_sdk) wraps the gRPC surface with a synchronous and an asynchronous client, bearer-token auth, plaintext or TLS channels, typed errors, and typed enums, over stubs generated from the service .proto. It covers the full gRPC surface.
Generate your own. The service is defined in one proto3 file, package voltnir.api.v1, service VoltAPI, the canonical description of the gRPC surface. Point your language's protobuf and gRPC codegen at it to generate a client. The .proto and the Python SDK are downloadable from the portal (§27).
23 · PERFORMANCE & LATENCY
Voltnir does not publish a headline latency number, because the figure that matters is the one your own deployment measures. The gateway times itself continuously and reports it, so you read latency from your install and your network path, not from a datasheet.
Measured, not asserted. For each market-data feed the gateway tracks two rolling averages: the per-delta processing time (the wall-clock cost of applying one update to the book) and the end-to-end latency (the gap between M7's frame timestamp and the moment Voltnir processes the frame). Both are computed from live samples over a sliding window and exposed on GET /api/v1/state, alongside a delta-per-hour throughput estimate, for the order-book feed and the private-data feed separately. The numbers come from real traffic, so they reflect your hardware and your link to the exchange.
Built for the hot path. The gateway is a single Rust binary with no garbage collector, so nothing pauses between a frame arriving and the book updating. The release build is link-time-optimised, single-codegen-unit, and aborts rather than unwinds on panic. The order book is held in memory and top of book is recomputed incrementally on each delta (§04), not rebuilt from scratch.
The same measurements gate trading. The feed's liveness, and these latency averages, are part of the health surface that decides whether an order may be sent: an order is refused when the book is not healthy (§24), so the performance numbers and the safety gate read from one set of observations.
24 · HEALTH, OBSERVABILITY & RESILIENCE
The gateway holds one EPEX SPOT session, gates trading on the health of that session, exposes what it sees over the same API as everything else, and rebuilds itself from the exchange after any drop. There is no separate metrics system to run.
order submission is refused unless the session is green exchange active ┐ AMQP consumers + publisher │ any one not green order-book connected │ → new order / modify 503 order-book synchronised │ cancel always allowed order-book sequence-healthy ┘ a cancel is never gated, so exposure can always be reduced while the feed is degraded (§07).
Feed health is a trading gate. A new order or a modify is refused with 503 unless the session is green: the exchange active, the AMQP consumers and publisher healthy, and the order-book feed connected, synchronised, and sequence-healthy. No order enters a degraded session. A cancel is exempt, so a resting order can always be pulled while the feed is down (§07).
Two read surfaces: session health and trading posture. GET /api/v1/state returns the live picture of the exchange session: the operational flag and, when it is false, the exact checks that are failing; the AMQP connection and consumer/publisher health; and, for the order-book and private-data feeds separately, whether each is connected, synchronised, and sequence-healthy, the time of its last pong and last delta, and the rolling processing-time and latency averages of §23. Alongside it, GET /api/v1/status returns the trading posture and risk picture in one read: the M7 throttling state, the trading and kill-switch flag, and the position and cash limits with their live consumption and remaining headroom, together with the licence. Both endpoints read identically over WebSocket and gRPC (state ↔ WatchState, status ↔ WatchStatus, §21), so a monitor can poll or subscribe over whichever transport it already speaks. The two are permission-scoped separately, read_state gates the state endpoints and read_status gates the status endpoints, so an operator dashboard can be granted exactly the view it needs. A separate GET /api/v1/system_info reports the exchange's own metadata: market id, M7 backend version and time zones, contract and trade store windows, and the M7 request-rate limits.
Throttle headroom. M7 throttles order traffic; the gateway tracks its order-management-transaction counts against M7's short- and long-window L1/L2 limits and surfaces them on REST (/throttling and the new /status), gRPC (GetThrottling and the new GetStatus/WatchStatus), and in the WebSocket status aggregate (§21), so a desk can see how close it is to a throttle before it hits one.
Self-healing transports. Both EPEX SPOT links reconnect on their own: AMQP with exponential backoff behind a 5-second heartbeat, WebSocket with exponential backoff (capped at 60 seconds) and pong-age liveness, a feed with no pong inside the configured window (15 seconds by default) is declared dead and reconnected. Each fails over to the exchange's alternate host. A sequence gap or duplicate on either feed is treated as corruption: the stream is dropped, the feed marked unsynchronised, and a fresh snapshot pulled, rather than serving a torn book.
Rebuilt from the exchange. Live state is not checkpointed to disk and replayed; it is reconstructed from M7. On reconnect the exchange re-delivers the order-book snapshot and the gateway re-requests its order snapshot and recent trade history, so positions and P&L (§10) recompute from the source of truth. A restart loses no committed audit data, it is already in the store (§16), and rebuilds the rest from the exchange.
No scrape endpoint. Observability is the state, status, system-info, throttling, and audit surfaces above, read over the same REST, WebSocket, and gRPC transports as the trading API. There is no Prometheus or /metrics endpoint; monitoring integrates by polling /state or tailing the audit and M7-error streams.
25 · DEPLOYMENT
One static binary on a Linux host you control. It dials out to EPEX SPOT and listens for your clients; you put TLS and access control in front of it the way you already run the rest of your estate.
one binary · four listening ports · one outbound EPEX SPOT dial rest_server.port 3000 REST plain HTTP, front with TLS ws_server.port 9001 WebSocket plain WS, front with TLS grpc_server.port 3443 gRPC plaintext or in-process TLS trading_terminal.port 8080 terminal SPA plain HTTP, front with TLS → outbound to EPEX SPOT M7 fixed per-environment endpoints (§03) nothing inbound toward the exchange defaults shown; every port and toggle is set in config.yml, read once at startup. A malformed config is fatal, never half-applied.
One binary. Voltnir ships as a single statically-linked Linux x86-64 binary (musl) with no runtime dependencies; the build refuses to ship if it has any dynamic library links. Both database drivers (§17) are inside it, chosen in config, so switching stores needs no rebuild. The React terminal (§26) is embedded in the binary and served from it.
Four listening ports, fronted by your own TLS. REST on 3000, WebSocket on 9001, gRPC on 3443, and the embedded terminal on 8080, each togglable and re-portable in config.yml. REST, WebSocket, and the terminal speak plain HTTP and WebSocket; the documented production setup binds them to localhost and fronts them with an on-prem nginx per-port TLS reverse proxy, so your existing certificate and access tooling apply. gRPC is the exception: it serves plaintext HTTP/2 by default but can terminate TLS itself (grpc_server.tls), so SDK clients connect to :3443 directly. The terminal is served a generated /config.js carrying the REST and WS ports and resolves the host from the browser, so one binary works on any hostname with no rebuild.
Outbound to EPEX SPOT, nothing inbound. The gateway dials EPEX SPOT M7 over its fixed per-environment endpoints (§03) and presents an outbound mutual-TLS client identity; EPEX SPOT never connects in. The only inbound listeners are your four client ports above.
Run it like a service. A sample systemd unit ships with the release, hardened (no new privileges, protected system, restricted address families) and set to restart on failure. config.yml is read once at startup, and a malformed or incomplete file is fatal by design, so a deployment fails loudly rather than running half-configured; the licence is the one thing re-checked at runtime (§15). A graceful restart is a permissioned action (RestartSystem) on REST, WebSocket, and gRPC: the process exits cleanly and the supervisor brings it back, with committed data intact and live state rebuilt from M7 (§24).
26 · TRADING TERMINAL
Voltnir ships a React trading terminal built on the exact public API any client uses, nothing privileged, nothing hidden. The binary serves it directly on port 8080. It reads the same WebSocket and REST surface documented in §20 and §21: the always-on order, trade, message, status, and P&L streams, the per-area contract feed, and the opt-in public-trade, tape, and cross-border streams, over one authenticated session. Anything the terminal shows, your own code can read the same way.
The workspace. A single-screen workspace, not a generic tiling grid: a sortable contract table drives a price chart and a depth ladder, with the order blotter, P&L, trade tape, and own-order event log docked around them. The chart, the trade tape, and the event log each detach into a floating window or a real second-monitor browser window, and every panel's size, position, and open state persists locally. A status bar across the top carries connection and operational state, the live M7 throttle levels, venue-wide P&L, and inline controls to halt trading or set the position limit.
The chart. Candlesticks at 1, 5, 15, or 30 minutes, or a line, built from the public trade prints and a client-side tick buffer (M7 publishes no historical tick feed). Order-flow and technical studies layer on top, toggled from the toolbar or by single-key shortcut:
Over those it overlays your own resting orders, your executions and cancellations, best bid and ask, the mid, the session high and low, and a countdown to gate closure.
The order book and the ticket. The depth ladder shows aggregated size, order count, and price per level, filling to as many levels as the panel height allows, at least five a side, with the spread called out on a central spine. Click a level to open the order ticket pre-filled to cross it (a bid row sells into it, an ask row buys). The ticket exposes M7's full entry model (§06): regular, block, iceberg, balance, and pre-arranged orders; fill-or-kill, immediate-or-cancel, or resting; session, till-date, or no validity; entered live or hibernated, with hibernate and activate run on existing orders. Before you send, it previews the projected net position, the cash impact, and the share of your position and cash limits the order would consume, and walks the opposing book to estimate fill price, VWAP, and slippage. An order that crosses an elevated threshold arms a second confirm step that re-arms on any edit.
Positions, P&L, and the tape. P&L drills down four ways, global or per member, by contract or by product, each row carrying realized and unrealized figures against the signed position (§10); a position overview renders net MW per contract as a bar chart. The public trade tape streams executed prints with uptick and downtick marks, and a click jumps the workspace to that contract.
Cross-border at a glance. The XBID view is a pan-and-zoom map of Europe (§05), not a grid: each bidding zone is coloured by trading opportunity, the directional spread gated by the transfer capacity that would have to carry it, rather than by raw capacity. Toggle import against export, fold Italy's price zones onto a single fill, and read the best per-border spread straight off the map.
Compliance and administration. Permission-gated views mirror the gateway's own: an order and trade history query with report export, a live-tailing compliance event log, and the M7 exchange-fault feed, each behind the same read_audit and read_m7_errors grants as the API (§16). On a Desk licence (§15), user administration (create users, set their permissions and keys, assign members) and member administration (create members, set position limits, activate or deactivate) run from the terminal.
Built like an instrument. Nine built-in themes and a hidden tenth, a comfortable or compact density, single-key chart shortcuts, and fill and rejection toasts. A diagnostics panel reads the live connection, decompression, and latency health behind the status bar.
27 · ACCOUNTS & CUSTOMER PORTAL
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.28 · SIMULATOR ACCESS
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 SIM credentials, the ones issued with your EPEX SPOT simulation (ASIM) licence, and we bring everything else.