MetaScalp API

SDK & Source metascalp.io
Searching...

Connect your scripts and bots to MetaScalp to interact with exchange connections. Use HTTP REST to query data and execute trades, or WebSocket to receive real-time order, position, and balance updates.

HTTP API: Host: 127.0.0.1   Ports: 17845–17855   CORS enabled for all origins.

Socket API: Host: 127.0.0.1   Same port as HTTP   Endpoint: ws://127.0.0.1:{port}/

Getting started: 1. Launch MetaScalp. 2. Discover the HTTP port with GET /ping. 3. List connections with GET /api/connections. 4. Use connection IDs for REST or WebSocket operations.   Full documentation

Discovery
GET /ping Discover running instance ▸

Scan ports 17845–17855 to find the running MetaScalp instance. Returns the app name and version.

Ticker
POST /api/change-ticker Switch ticker in MetaScalp UI ▸

Changes the current ticker. The active window is always notified. When a named binding is provided, that binding's linked panels are also updated.

Accepts two request formats: a ticker pattern string (e.g. BINANCE:BTCUSDT.p) or explicit fields (exchange + market + ticker).

A binding is a named group of linked panels inside MetaScalp (e.g. a chart, order book, and trade feed showing the same ticker). Bindings are numbered "001"–"500" and configured by the user in the MetaScalp UI.

Format: EXCHANGE:SYMBOL.suffix — suffix: .p (futures), .m (margin), .o (options), omitted (spot)
Exchange and suffix are optional. Symbol may contain letters, digits, _, /, -, $, :.
Examples: BINANCE:BTCUSDT.p, BYBIT:ETHUSDT, OKX:BTC-USDT.m, HYPERLIQUID:cash:HOODUSDT0.p
Combo
POST /api/combo Open combo layout ▸

Opens a combo layout for the specified ticker in the MetaScalp UI.

Two mutually exclusive payload shapes are accepted — the shape decides the behaviour, there is no request flag:

  • { "ticker": "BTCUSDT" } — opens one combo layout for a single ticker (unchanged behaviour).
  • { "tickers": ["BTCUSDT", "ETHUSDT", "SOLUSDT"] } — opens one combo layout per ticker, in the order given.

The tickers form is a write validated as all-or-nothing: the whole list is validated first, and if any ticker resolves on no eligible connection, nothing is opened and the request returns 400 naming the rejected tickers. Supplying both ticker and tickers, or an empty tickers array with no ticker, also returns 400.

Both shapes accept an optional activate boolean (default true) that decides whether the newly opened combo takes focus:

  • activate: true or the field omitted — the combo opens and becomes the active/focused layout (unchanged default). On the tickers form, exactly one window (the last in the list) comes to the front, as today.
  • activate: false — the combo opens in the background: whatever window/tab you were on stays active. On the tickers form, no element of the list steals focus.
Connections
GET /api/connections List active exchange connections ▸

Returns all currently active exchange connections. Use the id from the response to query orders, positions, balances, or to subscribe via WebSocket.

FieldTypeDescription
idintegerConnection ID — use for all exchange operations
namestringUser-defined connection name
exchangestringExchange name
exchangeIdintegerExchange identifier
marketstringMarket display name
marketTypeintegerMarket type enum value
stateinteger0=Disconnected, 1=Connecting, 2=Connected, 3=Reconnecting, 4=Resetting
viewModebooleanRead-only (no trading)
demoModebooleanPaper trading
GET /api/connections/{connectionId} Get single connection ▸

Returns details for a single connection by ID.

Trading Operations
GET /api/connections/{connectionId}/tickers Get available tickers ▸

Returns all available trading pairs on a connection with price/size precision, trading status, and constraints.

Optional query parameter Refresh=true forces a fresh fetch of the ticker list from the exchange instead of returning the cached set.

GET /api/connections/{connectionId}/orders?Ticker=... Get open orders ▸

Returns open orders for a specific ticker on a connection. The Ticker query parameter is required.

FieldTypeDescription
idintegerExchange order ID
tickerstringTrading pair
clientIdstring?Client-generated order ID
sideinteger0 None, 1 Buy, 2 Sell
pricedecimalOrder price
sizedecimalOrder size
filledSizedecimalFilled amount
filledPricedecimalExecution price (0 if not yet filled)
remainingSizedecimalRemaining amount
statusinteger0 New, 1 Open, 2 Closed
typeinteger0 Limit, 1 Stop, 2 StopLoss, 3 TakeProfit, 4 Market
triggerPricedecimal?Trigger price for stop/conditional orders
createDatestring (ISO)Order creation timestamp
GET /api/connections/{connectionId}/leverage-limits?Ticker=... Get leverage limits ▸

Read-only. Reports, live from the venue, the maximum leverage the venue allows and the maximum position size available for a ticker at the current (or an explicitly supplied) leverage. Values come from the venue's own risk-limit tiers — nothing is locally computed, cached or defaulted. The Ticker query parameter is required.

FieldTypeDescription
connectionIdintegerConnection ID
tickerstringTrading pair
leveragedecimalThe leverage the max position is reported for — the supplied leverage query param if given, otherwise the venue's current leverage for this ticker
maxLeveragedecimal?Highest leverage the venue allows for this ticker. null means the venue reports no cap (e.g. a spot market with no leverage tiers) — never 0
maxPositiondecimal?Maximum position size available at the reported leverage (re-read per leverage). null means the venue reports no cap — never 0
QueryTypeDescription
TickerstringRequired. Trading pair.
LeveragedecimalOptional. Report the max position at this leverage instead of the venue's current leverage. Must be greater than 0.
GET /api/link-groups/{groupId}/orderbook-snapshots Order books of a link group ▸

Read-only. Returns a fresh REST order-book snapshot for every panel currently in link group groupId. The membership is read LIVE from the UI's own grouping — a panel unlinked or closed a moment ago is already absent, and nothing about linking is changed by this request. A link group is a number (the link value the UI shows), not a stored entity: an in-range group with no live members is a legitimate empty result (200 with an empty members list), NOT a 404. Valid group ids are 1–500.

Partial failure never fails the whole response. Each member is reported independently: a good book carries ok: true and a book object; a member that could not be snapshotted carries ok: false and a stable machine-readable reason token. One failing book never turns into a 500.

FieldTypeDescription
groupIdintegerThe link group (binding) id, 1–500
membersarrayOne entry per panel currently live in the group (empty when the group has no live members)
members[].externalIdguidThe panel's document id
members[].kindstring?orderBook or chart (null when the document could not be resolved)
members[].connectionIdinteger?The panel's connection id (null when unset/unresolved)
members[].tickerstring?The panel's ticker (null when unset/unresolved)
members[].okbooleantrue when a snapshot was returned, else false
members[].bookobjectPresent only when ok: true. Same shape as orderbook-snapshot: updateId, asks, bids, bestAsk, bestBid
members[].reasonstringPresent only when ok: false. A machine-readable token (see below)
reason tokenMeaning
resolve_failedThe document could not be resolved from the layout
not_an_order_bookThe member is a chart (no order book to snapshot)
no_connectionThe panel has no connection set
no_tickerThe panel has no ticker set
connection_not_openThe panel's connection is not currently open
market_service_unavailableThe connection has no active market service
unsupported_exchangeThe exchange has no REST snapshot endpoint (e.g. the Bybit family)
snapshot_unavailableThe exchange returned no snapshot
snapshot_failedFetching the snapshot threw

Status codes: 200 (including an empty group); 400 for a non-numeric or out-of-range groupId.

GET /api/connections/{connectionId}/positions Get open positions ▸

Returns all open positions on a connection (futures/margin markets).

GET /api/connections/{connectionId}/balance Get account balance ▸

Returns account balances for all assets on a connection.

POST /api/connections/{connectionId}/orders Place order ▸

Places a new order on the exchange through a connection. Returns an auto-generated clientId for order tracking and executionTimeMs indicating how long the exchange request took (in milliseconds).

FieldTypeRequiredDescription
tickerstringyesTrading pair symbol
sideintegeryes1 Buy, 2 Sell
pricedecimalyes*Order price (*required for non-market orders)
sizedecimalyesOrder size (must be > 0)
typeintegerno0 Limit (default), 1 Stop, 2 StopLoss, 3 TakeProfit, 4 Market
reduceOnlybooleannoClose-only, default false
POST /api/connections/{connectionId}/orders/cancel Cancel order ▸

Cancels an existing order on the exchange.

FieldTypeRequiredDescription
tickerstringyesTrading pair symbol
orderIdintegeryesExchange order ID
typeintegernoOrder type (default 0 Limit)
POST /api/connections/{connectionId}/orders/cancel-all Cancel all orders ▸

Cancels all open orders for a given ticker on the exchange.

FieldTypeRequiredDescription
tickerstringyesTrading pair symbol

Returns { status, cancelledCount } — cancelledCount: 0 if there are no open orders for that ticker.

Market Data
GET /api/connections/{connectionId}/cluster-snapshot Cluster snapshot ▸

Returns the current cluster (volume profile / footprint) data for a ticker. Each time column holds bid/ask volumes at every price level; columns are ordered oldest first, newest last. Without Columns the response has the classic shape: 100 time columns of which only the newest 10 carry data (the cluster backend's default page), the rest are empty time slots. With Columns=N the response holds exactly N columns (the newest N), all filled from backend history.

ParameterTypeRequiredDefaultDescription
TickerstringyesTrading pair symbol
TimeFramestringyesS30, M1, M5, M10, M15, M30, H1, H4, D1
ZoomIndexintno1Price aggregation factor (1 = raw levels)
Columnsintno0Number of columns to return, counted back from the newest (1 = newest). 0 / omitted keeps the classic 100-column response with the backend default page (10) filled. A positive value returns exactly that many columns, all filled from backend history. Values above 100 are clamped to 100. History is fetched in pages of 5 columns, so larger values take longer.

When ZoomIndex > 1, prices are grouped into buckets of ZoomIndex × PriceIncrement and volumes are summed.

Signal levels

Signal levels are price triggers attached to a (connectionId, ticker). A level fires when the last trade crosses its price in the direction of its triggerRule. An object created via the API is indistinguishable from one drawn by hand: same table, same visibility in every window showing that instrument. Read the whole set with GET /api/connections/{connectionId}/signal-levels?Ticker=...; delete one with DELETE .../signal-levels/{signalLevelId}, or all for a ticker with DELETE .../signal-levels?Ticker=.... GET returns each level as id, connectionId, ticker, price, isTriggered, triggerTime, triggerRule, plus (MC-421) note and the seven appearance fields lineThickness, lineStyle, lineColor, textSize, textColor, textAlignment, textStyle (each null when unset; the enum-backed ones are emitted as their string name, e.g. "Dashed").

GET /api/connections/{connectionId}/signal-levels?Ticker=... Get signal levels ▸

Returns all signal levels for a specific ticker on a connection, each with the fields described above (id, connectionId, ticker, price, isTriggered, triggerTime, triggerRule, note and the seven appearance fields, null when unset).

Read-modify-write: id, connectionId, ticker, isTriggered and triggerTime are accepted and ignored by the matching PUT, so the object returned here can be edited and sent straight back. Trigger state is owned by the signal engine and is never settable over the API.

POST /api/connections/{connectionId}/signal-levels Create a signal level ▸

Creates a signal level for a ticker. The ticker must have live order book data (best ask is required) — subscribe to its order book first.

FieldTypeRequiredDescription
tickerstringyesTrading pair symbol
pricedecimalyesTrigger price (must be > 0)
triggerRulestringnoFiring direction: LessThanEqual (fires when a trade price ≤ price) or GreaterThanEqual (fires when ≥ price), case-insensitive. Omitted → derived from best ask exactly as before: price ≤ bestAsk ? LessThanEqual : GreaterThanEqual.
notestringnoFree-text note/label for the level. Omitted → no note (unchanged).
lineThicknessnumbernoStroke thickness. Omitted → unset (today's theme-driven stroke).
lineStylestringnoSolid / Dashed / Dotted (case-insensitive, or the enum int). Omitted → unset.
lineColorstringnoLine colour as a hex string. Omitted → unset.
textSizenumbernoLabel font size. Omitted → unset.
textColorstringnoLabel colour as a hex string. Omitted → unset.
textAlignmentstringnoLeft / Center / Right (case-insensitive, or the enum int). Omitted → unset.
textStylestringnoNormal / Bold / Italic (case-insensitive, or the enum int). Omitted → unset.

Appearance is additive and optional (MC-421): every appearance field defaults to unset — a level created without them looks exactly as it does today.

Errors: missing ticker/price → 400; price ≤ 0 → 400 Price must be greater than zero; unknown triggerRule → 400 Invalid 'triggerRule'. Allowed values: LessThanEqual, GreaterThanEqual.; an invalid appearance enum value (lineStyle / textAlignment / textStyle) → 400 Invalid '{field}'.; no order book data for the ticker → 400; unknown connection → 404 Connection {id} not found.

PUT /api/connections/{connectionId}/signal-levels/{signalLevelId} Modify an existing signal level (partial) ▸

Partial in-place update of an existing signal level. Only the supplied fields are applied; the level keeps its identity (the same id row is modified — no delete + re-create). At least one of price / triggerRule must be present.

FieldTypeRequiredDescription
pricedecimalnoNew trigger price (must be > 0). Leaves triggerRule untouched.
triggerRulestringnoLessThanEqual / GreaterThanEqual, case-insensitive. Leaves price untouched.
notestringnoNew note. Leaves the other fields untouched.
lineThicknessnumbernoNew stroke thickness. Leaves the other fields untouched.
lineStylestringnoSolid / Dashed / Dotted (or the enum int). Leaves the other fields untouched.
lineColorstringnoNew line colour (hex). Leaves the other fields untouched.
textSizenumbernoNew label size. Leaves the other fields untouched.
textColorstringnoNew label colour (hex). Leaves the other fields untouched.
textAlignmentstringnoLeft / Center / Right (or the enum int). Leaves the other fields untouched.
textStylestringnoNormal / Bold / Italic (or the enum int). Leaves the other fields untouched.

Partial update (MC-421): the note and every appearance field follow the same partial-apply rule as price/triggerRule — only the fields present are written, the rest are left untouched. At least one of price / triggerRule / note / an appearance field must be present. An invalid appearance enum value → 400 Invalid '{field}'.

Re-arm on price move: if the level had already fired (isTriggered = true) and its price is changed, it is re-armed (isTriggered back to false, triggerTime cleared) so it can fire again at the new price.

Errors: invalid connection id → 400 Invalid connection ID; connection not open → 404 Connection {id} not found; invalid level id → 400 Invalid signal level ID; empty body / all fields null → 400 Request body must set at least one of 'price', 'triggerRule', 'note' or an appearance field.; price ≤ 0 → 400 Price must be greater than zero; unknown triggerRule → 400 Invalid 'triggerRule'. Allowed values: LessThanEqual, GreaterThanEqual.; level not found → 404 Signal level {id} not found.

DELETE /api/connections/{connectionId}/signal-levels/{signalLevelId} Remove signal level ▸

Removes a single signal level by ID.

DELETE /api/connections/{connectionId}/signal-levels?Ticker=... Remove all signal levels (by ticker) ▸

Removes all signal levels for a specific ticker on a connection.

DELETE /api/signal-levels/triggered Remove all triggered signal levels ▸

Removes all triggered signal levels across all connections and tickers.

Order Book Settings
GET /api/connections/{connectionId}/orderbook-settings?Ticker=... Get order book settings ▸

Returns all order book settings for a specific ticker on a connection. Settings include trading defaults, order book display configuration, tick thresholds, and cluster options.

PUT /api/connections/{connectionId}/orderbook-settings?Ticker=... Update order book settings (partial) ▸

Partial update — only send the fields you want to change. Omitted fields keep their current values. Changes are pushed live to the running panels. Returns the full updated settings object.

Enum fields: ShowRuler (None, Points, Percent, PercentVolume), ZoomType (Absolute, Percentage), ZoomStepMode (Linear, Logarithmic), SizeType (Coin, Usd).

Ticks section: ShowExecutedOrders (boolean, default true; absent behaves as true) paints the terminal's own executed-order overlay (fill chips + position-size chip) on the tape — set false to hide it.

Order Book Snapshot
GET /api/connections/{connectionId}/orderbook-snapshot?Ticker=... Fresh one-shot order book snapshot from the exchange ▸

Fetches a fresh order book snapshot directly from the exchange REST endpoint — no cache lookup, no WebSocket subscription side effects. Intended as a one-shot complement to orderbook_subscribe with FetchSnapshot=false: subscribe to deltas cheaply, then call this once per ticker when you actually need to seed the book.

Each call performs one REST request to the exchange. The caller is responsible for not exceeding the exchange's rate limit when invoking this endpoint for many tickers in quick succession.

Returns 501 Not Implemented for exchanges that don't expose a REST snapshot endpoint (e.g. Bybit USDT Perpetual, which only delivers snapshots over WebSocket).

User levels

User levels are the plain price levels a trader draws by hand in the order book and on the chart (a horizontal price line with a text label). They are attached to a (connectionId, ticker). An object created via the API is indistinguishable from one drawn by hand: same table, same label default, same visibility in every window showing that instrument. Read the whole set with GET /api/connections/{connectionId}/user-levels?Ticker=...; delete one with DELETE .../user-levels/{userLevelId}, or all for a ticker with DELETE .../user-levels?Ticker=.... GET returns each level as id, connectionId, ticker, price, name, date. Note on date: the value is stored as epoch seconds; it is returned as a nullable ISO-8601 timestamp but accepted on input as the raw epoch-seconds integer (to preserve fidelity with the hand-drawn horizontal-ray path, which stores the raw chart time). A plain level has date = null.

GET /api/connections/{connectionId}/user-levels?Ticker=... Get user levels ▸

Returns all user levels for a specific ticker on a connection, each with the fields described above (id, connectionId, ticker, price, name, note, date — the date is returned as a nullable ISO-8601 timestamp), plus the seven appearance fields lineThickness, lineStyle, lineColor, textSize, textColor, textAlignment, textStyle (each null when unset; the enum-backed ones are emitted as their string name). (MC-421 Step 15) note is the user level's own free-text note (null when unset), read back exactly as sent — the same as on a signal level; it is distinct from name.

Read-modify-write: id, connectionId and ticker are accepted and ignored by the matching PUT, so the object returned here can be edited and sent straight back. One exception: a level whose date is non-null is not round-trippable verbatim — GET emits ISO-8601 while PUT takes raw epoch seconds.

⚠ Stored and returned, but not drawn. Unlike a signal-level note, a user-level note is not rendered on the order book or on either chart engine: a user level has a single label slot and name already occupies it. The note is an API-level attribute only until the layout question is settled.

Errors: invalid connection id → 400 Invalid connection ID; unknown connection → 404 Connection {id} not found; missing Ticker query → 400 Query parameter 'Ticker' is required.

POST /api/connections/{connectionId}/user-levels Create a user level ▸

Creates a user level for a ticker. Unlike signal levels there is no market-data precondition — the hand-drawn path has none, so the API does not add one.

FieldTypeRequiredDescription
tickerstringyesTrading pair symbol
pricedecimalyesLevel price (must be > 0)
namestringnoLabel. Omitted → the exact hand-drawn default the app uses when a level is drawn by hand: today's date as dd.MM.yyyy.
notestringno(MC-421 Step 15) The user level's own free-text note, stored and read back exactly as on a signal level. Omitted → null (no note). Distinct from name.
dateinteger (epoch seconds)noOptional timestamp (the hand-drawn horizontal ray stores the chart time here). Omitted → null (a plain level).
lineThicknessnumbernoStroke thickness. Omitted → unset (today's theme-driven stroke).
lineStylestringnoSolid / Dashed / Dotted (case-insensitive, or the enum int). Omitted → unset.
lineColorstringnoLine colour as a hex string. Omitted → unset.
textSizenumbernoLabel font size. Omitted → unset.
textColorstringnoLabel colour as a hex string. Omitted → unset.
textAlignmentstringnoLeft / Center / Right (case-insensitive, or the enum int). Omitted → unset.
textStylestringnoNormal / Bold / Italic (case-insensitive, or the enum int). Omitted → unset.

Appearance is additive and optional (MC-421): every appearance field defaults to unset — a level created without them looks exactly as it does today.

Errors: missing ticker/price → 400 Invalid request body. 'ticker' and 'price' are required.; price ≤ 0 → 400 Price must be greater than zero; an invalid appearance enum value (lineStyle / textAlignment / textStyle) → 400 Invalid '{field}'.; invalid connection id → 400 Invalid connection ID; unknown connection → 404 Connection {id} not found.

PUT /api/connections/{connectionId}/user-levels/{userLevelId} Modify an existing user level (partial) ▸

Partial in-place update of an existing user level. Only the supplied fields are applied; the level keeps its identity (the same id row is modified — no delete + re-create). At least one of price / name / date must be present.

FieldTypeRequiredDescription
pricedecimalnoNew price (must be > 0). Leaves name / date untouched.
namestringnoNew label. Leaves the other fields untouched.
notestringno(MC-421 Step 15) New note (partial-update: supplied → overwrite, omitted → untouched). Distinct from name.
dateinteger (epoch seconds)noNew timestamp. Leaves the other fields untouched.
lineThicknessnumbernoNew stroke thickness. Leaves the other fields untouched.
lineStylestringnoSolid / Dashed / Dotted (or the enum int). Leaves the other fields untouched.
lineColorstringnoNew line colour (hex). Leaves the other fields untouched.
textSizenumbernoNew label size. Leaves the other fields untouched.
textColorstringnoNew label colour (hex). Leaves the other fields untouched.
textAlignmentstringnoLeft / Center / Right (or the enum int). Leaves the other fields untouched.
textStylestringnoNormal / Bold / Italic (or the enum int). Leaves the other fields untouched.

Partial update (MC-421): every appearance field follows the same partial-apply rule — only the fields present are written, the rest are left untouched. At least one of price / name / note / date / an appearance field must be present.

Errors: invalid connection id → 400 Invalid connection ID; connection not open → 404 Connection {id} not found; invalid level id → 400 Invalid user level ID; empty body / all fields null → 400 Request body must set at least one of 'price', 'name', 'date', 'note' or an appearance field.; an invalid appearance enum value → 400 Invalid '{field}'.; price ≤ 0 → 400 Price must be greater than zero; level not found → 404 User level {id} not found.

DELETE /api/connections/{connectionId}/user-levels/{userLevelId} Remove user level ▸

Removes a single user level by ID.

Errors: invalid connection id → 400 Invalid connection ID; unknown connection → 404 Connection {id} not found; invalid level id → 400 Invalid user level ID.

DELETE /api/connections/{connectionId}/user-levels?Ticker=... Remove all user levels (by ticker) ▸

Removes all user levels for a specific ticker on a connection.

Errors: invalid connection id → 400 Invalid connection ID; unknown connection → 404 Connection {id} not found; missing Ticker query → 400 Query parameter 'Ticker' is required.

Chart annotations

Chart annotations are the free-form shapes a trader draws on the chart, attached to a (connectionId, ticker). There are three separate lists — trend lines, horizontal-lines and horizontal-rays. An object created via the API is indistinguishable from a hand-drawn one by construction: every write (API and sidebar alike) goes through the same annotation service, so an API-created shape appears identically in every chart environment showing that instrument (both the SciChart and TradingView engines read the same rows — the store is per-(connectionId, ticker), not per-engine).

No per-item identity. Annotations carry no id — the only handle is the zero-based index within its own list. GET stamps each item with its index; POST appends one; DELETE .../{type}/{index} removes by that index (out of range → 404, list unchanged). The primary write is PUT .../{type}, a whole-list replace for one type. Because addressing is by index and every edit replaces the list, concurrent editors are last-writer-wins (inherent to the id-less model, not a defect).

{type} is one of lines / horizontal-lines / horizontal-rays. Geometry by type: a line is { x1, x2 (ISO-8601 datetime), y1, y2 (number) }; a horizontal-line is { y }; a horizontal-ray is { y, x1 (ISO-8601 datetime) }. The ?Ticker= query is required on every route; an unknown connection → 404 Connection {id} not found.

Prices are venue prices — in both directions. The y / y1 / y2 fields are the instrument's real market price (e.g. 0.2 for ADAUSDT). POST/PUT a venue price and it lands on the chart at that price; GET returns the on-screen venue price of every shape, including hand-drawn ones. Timestamp fields (x1 / x2) are unchanged.

⚠ Breaking change (from build 1.0.2835-MC-400). Earlier builds passed y / y1 / y2 through unconverted, exposing the chart's internal axis units (the price axis is scaled per instrument — ×104 for ADAUSDT), so a line POSTed at 0.2 landed off-scale and a hand-drawn shape read back at ≈1999.79. These fields now carry venue prices, converted per (connectionId, ticker) from that instrument's own price increment. Any client that was compensating by multiplying/dividing by the axis factor must stop doing so and send/expect plain venue prices.

Unknown / zero-increment instrument. The conversion needs the instrument's price increment. If the Ticker is not known on the connection, or its price increment is ≤ 0, the request is rejected with 400 Ticker '{ticker}' not found on connection {connectionId} on GET, PUT and POST — the API never silently falls back to raw axis units (which would reintroduce the off-scale defect for that one instrument).

Live redraw of open charts. A POST/PUT/DELETE now redraws every already-open chart of that (connectionId, ticker) immediately, without reopening. This is an in-app change only: the client-facing contract is unchanged — there is no new annotations_* WebSocket push and no new unit conversion. Requests, responses and the venue-price units above are exactly as before.

GET /api/connections/{connectionId}/annotations?Ticker=... Read all three annotation lists for a ticker ▸

Returns the whole set as { connectionId, ticker, lineAnnotations[], horizontalLineAnnotations[], horizontalRayAnnotations[] }. Each item carries its geometry plus its zero-based index in its own list — the only handle usable for DELETE .../{type}/{index}.

ListItem fields
lineAnnotationsindex, x1, x2 (ISO-8601 datetime), y1, y2 (venue price)
horizontalLineAnnotationsindex, y (venue price)
horizontalRayAnnotationsindex, y (venue price), x1 (ISO-8601 datetime)

The y / y1 / y2 values are venue prices, converted out of the chart's internal axis units per (connectionId, ticker). A hand-drawn shape reads back at its on-screen price (e.g. 0.2, not ≈1999.79).

Since MC-400 Step 15c: this GET also returns shapes a trader draws by hand on the TradingView chart — horizontal_line, horizontal_ray and trend_line tools are written back into the same shared store as they are created / moved / removed, and appear here in venue prices exactly like API-created shapes (the TradingView bundle dist/bundle.js was rebuilt for this).

Errors: invalid connection id → 400 Invalid connection ID; unknown connection → 404 Connection {id} not found; missing Ticker query → 400 Query parameter 'Ticker' is required; unknown ticker or PriceIncrement ≤ 0 → 400 Ticker '{ticker}' not found on connection {connectionId}.

PUT /api/connections/{connectionId}/annotations/{type}?Ticker=... Replace the whole list for one type (primary op) ▸

The primary write: replaces the entire list for {type} with the posted array. The body is a JSON array of items of that type's geometry (a null / empty body clears the list). This is how a client persists edits — read with GET, mutate the array client-side, PUT it back.

Concurrency — last-writer-wins. Because the annotation model has no per-item identity (the only handle is the list index) the write is a whole-list replace, not a per-item patch. If the sidebar and the API (or two API clients) edit the same (connectionId, ticker) list concurrently, the last PUT overwrites the other's changes wholesale. Read immediately before you PUT to minimise the window.

{type}Body (array of)
lines{ x1, x2 (ISO-8601 datetime), y1, y2 (venue price) }
horizontal-lines{ y (venue price) }
horizontal-rays{ y (venue price), x1 (ISO-8601 datetime) }

Send y / y1 / y2 as venue prices; they are converted to the chart's axis units per (connectionId, ticker) before storage, so the shape lands at the price you sent.

Errors: unknown {type} → 400 Unknown annotation type '{type}'. Allowed values: lines, horizontal-lines, horizontal-rays.; invalid connection id → 400; unknown connection → 404; missing Ticker → 400; unknown ticker or PriceIncrement ≤ 0 → 400 Ticker '{ticker}' not found on connection {connectionId}.

POST /api/connections/{connectionId}/annotations/{type}?Ticker=... Append one annotation of a type ▸

Appends a single annotation to the {type} list. The body is one item (not an array) of that type's geometry, with y / y1 / y2 as venue prices (converted to axis units per (connectionId, ticker) before storage). Implemented as read-modify-write inside the one action (read the current list, append, replace the whole list) — the list is never cached across requests. The appended item lands at the last index.

Errors: unknown {type} → 400; empty / wrong-shape body → 400 Request body must be a {type} annotation.; invalid connection id → 400; unknown connection → 404; missing Ticker → 400; unknown ticker or PriceIncrement ≤ 0 → 400 Ticker '{ticker}' not found on connection {connectionId}.

DELETE /api/connections/{connectionId}/annotations/{type}/{index}?Ticker=... Remove one annotation by zero-based index ▸

Removes the annotation at the given zero-based {index} in the {type} list (read-modify-write inside the one action). An out-of-range index is a 404 and the stored list is left unchanged (the whole-list replace is not performed). The index is the value stamped on each item by GET.

Errors: unknown {type} → 400; non-numeric index → 400 Invalid annotation index; index out of range → 404 {Type} annotation index {index} not found; invalid connection id → 400; unknown connection → 404; missing Ticker → 400.

DELETE /api/connections/{connectionId}/annotations?Ticker=... Clear all three lists for a ticker ▸

Clears all three annotation lists (lines, horizontal-lines, horizontal-rays) for the (connectionId, ticker) in one call — the same action the «clear all shapes» sidebar command performs.

Errors: invalid connection id → 400; unknown connection → 404; missing Ticker → 400.

Screener templates

Manage the saved screener templates (the named sets of columns, filters and market selections the market screener uses) without opening a window. Every open screener window listens to the same OnTemplatesChanged broadcast the app raises internally, so a create / edit / delete made here is reflected live in the window's template list — no reopen needed. The list always includes the synthetic read-only Default template (id -1): it is list-only and cannot be fetched, edited or deleted individually. Bodies are camelCase; the settings object is the full template configuration and round-trips verbatim.

GET /api/screener/templates List all screener templates ▸

Returns { count, templates[] }. Each template is { id, name, settings } where settings carries the whole configuration (columnSettings[], columnFilters[], marketFilters[], coinTags[], numberOfRows, activeFilter, the new-coin flags, …). The synthetic Default template (id -1) is always first.

GET /api/screener/templates/{templateId} Read one template's resolved configuration ▸

Returns the single template { id, name, settings }.

Errors: non-numeric {templateId} → 400 Invalid template ID; unknown id (including the synthetic Default -1) → 404 Screener template {id} not found.

POST /api/screener/templates Create a template (name + optional settings) ▸

Body { name, settings? }. name is required; settings is optional — omit it and the template is created with the default configuration. The full settings blob (columns / filters / market selections) is persisted, so a template created here carries its filters and columns, not just its name. Returns the created { id, name, settings }.

Errors: missing / blank name → 400 Request body must carry a non-empty 'name'.; body that is not valid JSON → 400 Request body is not valid JSON.

PUT /api/screener/templates/{templateId} Edit a template (partial: null keeps current) ▸

Body { name?, settings? }. A null / omitted name keeps the stored name and a null / omitted settings keeps the stored blob (partial update). Returns the updated { id, name, settings }.

Errors: non-numeric {templateId} → 400; body that is not valid JSON → 400; unknown id → 404 Screener template {id} not found (checked before any write).

DELETE /api/screener/templates/{templateId} Delete a template ▸

Removes the template. Returns { status: "ok" }.

Errors: non-numeric {templateId} → 400; unknown id (including the synthetic Default -1) → 404 Screener template {id} not found (checked before any write).

GET /api/screener/templates/{templateId}/data Snapshot a template's full data (all rows, headless) ▸

Returns ONE headless snapshot of the full row set of the given template — every row an open screener window on the same template would show — without opening a screener window. A fresh background screener socket is subscribed, the first complete frame captured, and the socket disposed; there is no streaming. The synthetic Default (id -1) is answered too (it resolves to the default configuration, exactly like an open window's fallback).

Response { templateId, count, rows[] }. Each row: { ticker, wireTicker, isNewCoin, exchange, listedExchanges[], columns[] } where exchange / listedExchanges are screener exchange keys ("binance_s", "bybit_f", "polymarket") and each columns[] entry is { type, timeFrame, time, metric, value }.

Column numbers (type) — the same in /data and in screener_frame; the value is the terminal's internal column id, so the list is re-issued whenever the terminal adds or removes a column (last change: MET-1827 removed Activity, the former 7, and every later column moved down by one — builds up to Beta 1.0.756 still send the old numbers). Current numbers (Dev 1.0.4357 and later, MET-1833): 2 Volume $ · 3 Trades · 4 Change % · 5 NATR % · 6 Spread % · 7 Funding % · 8 Next funding time (epoch seconds) · 9 Funding interval (seconds) · 10 Volume spike % · 11 Trades spike % · 12 OI change % · 13 OI change $ · 14 Δ volume % · 15 Δ volume $ · 16 Price. (0 Actions and 1 Ticker are window-only columns and carry no value.)

Errors: non-numeric {templateId} → 400 Invalid template ID; unknown id → 404 Screener template {id} not found (checked before any subscribe). If the screener backend produces no rows within the deadline, rows is an empty array (count = 0).

Notifications

Push your own row into MetaScalp's notification feed (the «Line notifications» window). An injected notification is indistinguishable from a native one except for its event-type label: it shares the same feed, the same 200-row cap, the same ordering, and is delivered live over the WebSocket as notification_update. Every field is optional — a field you do not send renders as an empty cell. Nothing is persisted (in-memory only). The feed's sound and the settings checkboxes do not apply to an injected row.

POST /api/notifications Inject a custom notification into the feed ▸

Builds a notification and adds it to the feed. All fields are optional. The eventType is an arbitrary string rendered verbatim in the Event Type column (no fixed enum). A field that is not sent leaves that column empty — price and size stay blank (no 0 / -1 sentinel).

FieldTypeRequiredDescription
timestring (ISO-8601)noNotification timestamp. Omitted → the request time (server UTC now).
connectionIdintegernoWhen supplied, fills the exchange icon + S/F badge and the exchange/market/colour cells from that connection. Omitted → those cells stay empty. Unknown id → 404.
tickerstringnoInstrument label. Omitted → empty cell.
eventTypestringnoFree-text label shown in the Event Type column, verbatim. Omitted → empty cell.
sizedecimalnoSize cell. Omitted → empty (null, no sentinel).
pricedecimalnoPrice cell. Omitted → empty (null, no sentinel).
tabNamestringnoTab-name cell. Omitted → empty cell.

Success → 200 { "status": "ok" }. The row is broadcast to subscribed WebSocket clients as a notification_update whose type is the supplied eventType (or, for native notifications, the notification-type name as before).

Notifications globally disabled still answers 200. This endpoint injects directly into the in-memory feed and its WebSocket broadcast; it does not consult the user's global «show notifications» toggle or the per-type settings checkboxes. A request therefore succeeds (and is delivered to subscribed WS clients) even when the desktop UI would suppress the equivalent native pop-up. Treat delivery as feed-level, not UI-visibility-level.

Errors: unknown connectionId → 404 Connection {id} not found.

UI inventory (read-only)

Two read-only endpoints describe the whole open interface — every window, its tabs, and the order books and charts inside each — plus standalone chart windows. The tree is rebuilt entirely from the saved layout in the database, never by reading the live on-screen windows (each runs on its own UI thread and cannot be touched from the API). Every field is always present: a value the system does not store, or that only exists at runtime, is reported as null, never omitted. Nothing is opened, closed or changed.

GET /api/ui/state Full open-UI inventory ▸

Returns the whole open UI as { "windows": [ ... ], "standaloneCharts": [ ... ] }. Each window carries its geometry, monitor and tabs; each tab its documents (order books and charts); each document its identity, connection, ticker, link number and docking position, plus kind-specific fields.

Window

FieldTypeNullableDescription
idintegernoPersisted window id.
typestringnoWindow type (Workspace / MainWorkspace / LightWorkspace). A window has no title and no persisted focus flag — both are dropped; the «title» is a tab concept (tab.name) and the «active» signal is tab.isSelected.
geometryobjectno{ top, left, width, height, isFullScreen, pinOnTop, isTopBarHidden }. pinOnTop is nullable (tri-state in the model).
monitorintegeralways nullWould be computed from geometry vs the screen list, but screen enumeration lives in a UI assembly the API does not reference; no dispatcher-safe helper is reachable, so this is reported as null (pre-authorized fallback).
tabsarraynoThe window's tabs (see below).

Tab

FieldTypeNullableDescription
idintegernoPersisted tab id.
namestringyesTab name (the «title»).
orderIdintegernoTab ordering index.
isSelectedbooleannoWhether this is the active tab in its window (the only persisted «active» signal).
colorstringyesTab colour, when set.
documentsarraynoThe order books and charts on this tab.

Document (common fields)

FieldTypeNullableDescription
idintegernoPersisted document id.
externalIdstring (GUID)noThe stable id the docking layout XML joins on (its ContentId).
kindstringnoorderBook or chart.
connectionIdintegeryesOwning connection; null when the document is not bound to a connection.
tickerstringyesInstrument label; null when unset.
linkNumberintegeryesThe link/binding group id read from the runtime binding bus (bindingTypeId, not a column). null when the document is not a binding source. DB-backed — no UI thread is used.
layoutPositionobjectyes{ paneGroupIndex, documentIndex, orientation } parsed from the tab's AvalonDock layout XML (matched by externalId == ContentId). null for standalone charts (no tab layout) and for documents absent from the layout XML.
orderBookobjectyesOrder-book details (below); null when kind != orderBook.
chartobjectyesChart details (below); null when kind != chart.

Order-book details (document.orderBook)

FieldTypeNullableDescription
baseAssetstringyesBase asset.
quoteAssetstringyesQuote asset.
priceIncrementdecimalnoPrice tick size.
sizeIncrementdecimalnoSize step.
minSizedecimalnoMinimum order size.
maxSizedecimalyesMaximum order size, when defined.
stepPricedecimalyesStep price, when defined.
statestringnoOrder-book state (Open / Closing).
isTradingAllowedbooleannoWhether trading is enabled on this book.
pinMarketTypebooleannoWhether the market type is pinned.
zoomIndexintegeryesThe order-book zoom (aggregation) index, resolved from the persisted order-book settings set (OrderBookSettingsSet.ZoomIndex) via a non-side-effecting read. null only when the document has no connection/ticker or no settings row exists. This is not the market-data per-WS-subscription zoomIndex.

Chart details (document.chart)

FieldTypeNullableDescription
timeFramestringnoChart time frame enum name (None, M1, …).
enginestringnoChart engine: Regular or TradingView.
xVisibleRangeDiffintegernoVisible x-range span.
pinMarketTypebooleannoWhether the market type is pinned.
geometryobjectyesOwn window geometry for standalone charts; null for charts docked inside a tab.

standaloneCharts is a top-level array of chart documents (same document shape) for chart windows not attached to any tab (tabId == null); each includes its own chart.geometry and never appears under a tab.

Other open window families

Alongside windows and standaloneCharts, the inventory lists every OTHER open window family the open route (POST /api/ui/windows) can create. Every key is always present; where a value is absent it is null (never omitted), and a family that fails to read degrades to its own empty list / null object without blanking the rest.

FieldTypeNullableDescription
watchlistsarraynoOpen watchlist windows, each { id, externalId, geometry }. A watchlist row has no visibility flag — every listed row is an open window.
screenersarraynoOpen screener windows, each { id, externalId, name, templateId, geometry }. A screener row exists only while its window is open (created on open, removed on close), so every listed row is open.
lineNotificationsobjectyesThe line-notifications singleton: { id, isOpen, geometry }. The row is always persisted; isOpen is true only when the window is actually shown — a persisted-but-hidden singleton reads isOpen: false. null if the family could not be read.
finresobjectyesThe finres singleton, same shape/semantics as lineNotifications.
userTradesobjectyesThe user-trades singleton, same shape/semantics.
listingobjectyesThe listing singleton, same shape/semantics.

Success → 200 with the object above.

GET /api/ui/windows/{windowId} One window's inventory ▸

Returns exactly the same single-window object as that window's entry in /api/ui/state — same geometry, tabs and documents.

Optional ?windowType= query. The window ids returned by POST /api/ui/windows are per-surface: a chart / watchlist / screener id is unique only within its own surface, not against the workspace Windows table this route reads by default. Supply ?windowType= with one of chart, tradingViewChart, watchlist or screener (case-insensitive) to resolve the id within that surface instead — letting a client read a window back at the very address the create route returned. The response is then { id, type, document | watchlist | screener } (exactly one of the three type blocks is populated). Omit the query for byte-for-byte today's Windows-table lookup.

Errors: a non-numeric windowId → 400 Invalid window ID; an unknown window (in the default table, or within the requested windowType surface, or an unrecognised windowType) → 404 Window {id} not found.

PUT /api/ui/documents/{externalId}/link-number Set a panel's link number ▸

Sets the link (binding-group) number of one addressed panel — the same linkNumber reported by GET /api/ui/state (document.linkNumber). {externalId} is the document's GUID.

Request body:

{ "linkNumber": 3 }
FieldTypeRequiredDescription
linkNumberintegeryesBinding-group id: 1 = active window, 2–500 = a user link, 9999 = cancel link. Must be positive.

Persisted immediately via the binding bus (a DB-backed write — no UI thread is used). The stored link changes at once; a panel already on screen may not fully re-wire its live link until it is reloaded. Affects only the addressed document.

Asynchronous w.r.t. the UI thread. Success → 200 { "status": "ok" }.

Errors: a non-GUID externalId → 400 Invalid document ID; a missing/omitted linkNumber → 400; a non-positive value → 400; an unknown document → 404 Document {externalId} not found (nothing is written).

POST /api/ui/windows Open a terminal window at a place/size ▸

Opens ONE terminal window of the requested type at an explicit position and size, on an explicit monitor. Geometry is entirely optional: omit top/left/ height/width and the window lands exactly where it lands today for that type — including MetaScalp's open-at-the-cursor placement where the app does that today (workspace, watchlist, screener, line-notifications, finres, user-trades). A supplied rect is clamped to fit the target monitor. Position is in virtual-desktop coordinates; monitor (0-based) selects the screen.

Request body:

{ "windowType": "watchlist", "top": 100, "left": 200, "height": 450, "width": 300, "monitor": 0 }
FieldTypeRequiredDescription
windowTypestringyesOne of: workspace, lightWorkspace, chart, tradingViewChart, watchlist, screener, lineNotifications, finres, userTrades, listing.
topnumbernoVirtual-desktop Y. Omit for today's default placement.
leftnumbernoVirtual-desktop X. Omit for today's default placement.
heightnumbernoWindow height. Omit for the type's default.
widthnumbernoWindow width. Omit for the type's default.
monitorintegerno0-based target screen. Omit to place by virtual-desktop coordinate.
templateIdintegernoScreener only. Open the screener already showing this saved template's filters and rows — the same state the window's own template picker produces. Omit for today's Default view. The synthetic Default (id -1) is accepted and resolves to Default. Ignored for any non-screener windowType.

Response { windowId, windowType, top, left, height, width } — where the window actually landed (post-clamp). For the four singleton types (lineNotifications, finres, userTrades, listing) windowId is null: each has a single fixed row (id 1, which would collide with the main workspace) and its close/activate routes ignore the id entirely, so there is no addressable per-surface id to return. Every other type returns its real per-surface id.

Opening the screener on a template. For windowType: "screener" a supplied templateId is threaded onto the screener window so it loads that template exactly as picking it by hand would; because the choice is persisted with the window, it also becomes the window's template on the next launch. Omitting templateId is bit-for-bit today's behaviour (the app's Default view).

Errors: a malformed body → 400 Malformed request body.; a missing windowType → 400 with the supported list; an unknown or unsupported-standalone type (e.g. settings, which is modal, or the full-screen chart, which needs an existing chart) → 400 naming the supported list; an unknown screener templateId → 404 Screener template {templateId} not found — all validated before any window is opened.

POST /api/ui/windows/{windowId}/close Close a terminal window ▸

Closes ONE terminal window through the app's OWN close path (the same path a manual X uses, so it inherits today's close-confirmation-popup behaviour). {windowId} is the id WITHIN the surface named by windowType — ids are not unique across surfaces (a workspace, a chart, a screener and a watchlist can all be id 1), so the body's windowType is what routes the id to the correct window. The singleton surfaces (lineNotifications, finres, userTrades, listing) ignore the path id. finres is HIDDEN rather than destroyed, matching its manual toggle.

Request body:

{ "windowType": "workspace" }
FieldTypeRequiredDescription
windowTypestringyesOne of: workspace, lightWorkspace, chart, tradingViewChart, watchlist, screener, lineNotifications, finres, userTrades, listing.

Response { windowType, windowId, closed, outcome }.

The main workspace can never be closed through the API — it would quit the terminal. A windowId that resolves to the main workspace → 400 The main workspace cannot be closed through the API. (+ supported list).

Errors: a non-numeric {windowId} → 400 Invalid window ID; a malformed body → 400 Malformed request body.; a missing / unknown / unsupported windowType → 400 naming the supported list; an unknown window id → 404 — all validated before any window is closed.

POST /api/ui/windows/{windowId}/activate Bring a terminal window to the front ▸

Brings ONE terminal window to the front on its own window thread. Same id-addressing as the close route: {windowId} is the id within the surface named by windowType; singletons ignore the id.

Request body:

{ "windowType": "chart" }
FieldTypeRequiredDescription
windowTypestringyesOne of the ten window types (see close).

Response { windowType, windowId, activated, outcome }.

Errors: a non-numeric {windowId} → 400 Invalid window ID; a malformed body → 400; a missing / unknown / unsupported windowType → 400 naming the supported list; the main workspace → 400; an unknown window id → 404.

POST /api/ui/tabs/{tabId}/close Close a tab by id ▸

Closes the ONE tab addressed by {tabId} through the app's OWN tab-close path (the same teardown the tab's own × runs — every document in the tab is torn down and its DB rows deleted). {tabId} is the id a tab carries under GET /api/ui/state (Window → Tab). Tab ids are globally unique, so no window type is needed — the id alone addresses the tab, and the window it belongs to and its sibling tabs stay open.

No request body is required (an empty body or {} is accepted).

Response { tabId, closed, outcome }.

Unlike a hand-close, the API path raises NO confirmation prompt (a modal would block the HTTP response); the interactive prompt — and its «Show a warning when closing windows and tabs» setting — is unchanged.

A window's ONLY tab cannot be closed → 400 with a message (it would leave an empty workspace); there is no silent no-op.

Errors: a non-numeric {tabId} → 400 Invalid tab ID; a malformed body or an unknown body property → 400; an id no open window hosts → 404 Tab {tabId} not found. — all validated before any tab is closed.

POST /api/ui/tabs/{tabId}/activate Switch to a tab by id ▸

Makes the tab addressed by {tabId} the selected one in its window, by driving the SAME header selection a user's tab click drives (which also persists it). Same id-addressing as the close route; no request body is required. Idempotent when the tab is already selected.

Response { tabId, activated, outcome }.

Errors: a non-numeric {tabId} → 400 Invalid tab ID; a malformed body or an unknown body property → 400; an id no open window hosts → 404 Tab {tabId} not found.

POST /api/close-last-tab Close the main window's last tab ▸

Closes the main workspace window's LAST tab through the tab's own close path (no confirmation prompt). It never closes the only remaining tab. This is a fixed-target convenience route (no id) — to close a SPECIFIC tab by id, use POST /api/ui/tabs/{tabId}/close.

No request body is required. Response { status } — "ok" when a tab was closed, "skipped" when only one tab remained.

PUT /api/ui/documents/{externalId}/ticker Re-point one panel to a market ▸

Re-points one addressed panel (order book or chart) to a different market. This targets only that panel — unlike POST /api/change-ticker, which drives the active window and a whole named link group. {externalId} is the document's GUID.

Request body:

{ "ticker": "ETHUSDT", "connectionId": 77 }
FieldTypeRequiredDescription
tickerstringyesThe instrument to switch the panel to.
connectionIdintegernoTarget connection. Omit to keep the panel's current connection (a plain re-ticker on the same venue).

Asynchronous w.r.t. the UI thread. The API resolves the document and dispatches a change-ticker action to that panel's own live listener, which re-resolves the ticker and re-tickers itself on its own window dispatcher; the visible change and its persistence happen inside the panel. Success → 200 { "status": "ok" }.

Errors: a non-GUID externalId → 400 Invalid document ID; a missing ticker → 400; an unknown document → 404 Document {externalId} not found; a document with no current connection and no supplied connectionId → 400.

GATED UI control — deferred lifecycle routes Documented limitation ▸

Opening/closing an individual panel (a single order book or chart inside a tab) is not yet available. Whole-window lifecycle and per-tab close/activate ARE available (see the routes above); only the panel-level routes below remain deferred. Faking them by writing the database directly would diverge the on-screen layout from the saved model, so they are deliberately deferred to a dedicated follow-up rather than shipped fragile. The following routes therefore do not exist yet:

RouteIntent
POST /api/ui/windows/{windowId}/order-booksOpen an order book in a window's active tab
POST /api/ui/windows/{windowId}/chartsOpen a chart in a window's active tab
DELETE /api/ui/documents/{externalId}Close one addressed panel (with its DB delete)
Real-time WebSocket
WS ws://127.0.0.1:{port}/ Real-time updates via WebSocket ▸
Disconnected

MetaBroker analytics streams: app-wide, no connection ID, no MetaBroker login. Other options (coefficients, lifetimes, quote assets, aggregationMs, side, coin, assetClass) go through the custom message box — payloads are in the message reference below.

Waiting for connection...
Subscribe to order book
to see live depth
Subscribe to trades
to see live feed

▸ Message reference & examples connection, envelope, inbound / outbound messages, lifecycle, example payloads

Connect via WebSocket to receive real-time updates for your exchange connections. Subscribe by connection ID for order, position, balance, and finres events. Subscribe by connection ID + ticker for real-time trade and order book market data.

Connection

The WebSocket server shares the same port as the HTTP API (17845–17855). Connect to ws://127.0.0.1:{port}/.

Message format

All messages (inbound and outbound) are JSON with this envelope:

{ "Type": "message_type", "Data": { ... } }

Inbound messages (client → server)

Connection-level — subscribe by connection ID for order, position, balance, and finres updates:

TypeDataDescription
subscribe{ "connectionId": 123 }Subscribe to updates for a connection. Connection must be active. Idempotent.
unsubscribe{ "connectionId": 123 }Stop receiving updates for a connection. Idempotent.

Market data — subscribe by connection ID + ticker for trade or order book updates:

TypeDataDescription
trade_subscribe{ "connectionId": 123, "ticker": "BTCUSDT", "zoomIndex": 1 }Subscribe to real-time trade updates for a specific ticker. Optional zoomIndex — when > 1, trades are aggregated by zoomed price level. Idempotent — re-subscribing updates zoomIndex.
trade_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving trade updates for that ticker. Idempotent.
orderbook_subscribe{ "connectionId": 123, "ticker": "BTCUSDT", "zoomIndex": 0, "depthLevels": 50, "depthPercent": 0.5, "fetchSnapshot": true }Subscribe to order book updates (snapshot + incremental). Idempotent — re-subscribing replaces depth params. Optional zoomIndex (aggregation), depthLevels (top-N per side, snapshot only), depthPercent (per-side band on best ask / best bid, snapshot + updates). Optional fetchSnapshot (default true) — when false AND this is the first subscriber for the ticker, the exchange REST snapshot fetch is skipped, so you receive only delta updates (useful for mass-subscribing to many tickers without hitting exchange REST rate limits; seed state separately via GET /api/connections/{id}/orderbook-snapshot).
orderbook_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving order book updates for that ticker. Idempotent.
mark_price_subscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Subscribe to mark price updates. No initial snapshot — only live updates. Idempotent.
mark_price_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving mark price updates for that ticker. Idempotent.
index_price_subscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Subscribe to index price updates (futures only). No initial snapshot — only live updates. Idempotent.
index_price_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving index price updates for that ticker. Idempotent.
funding_subscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Subscribe to funding rate updates (perpetual futures only). No initial snapshot. Idempotent.
funding_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving funding updates for that ticker. Idempotent.

Outbound messages (server → client)

Acknowledgements:

TypeWhenData fields
subscribedAfter successful connection subscribeconnectionId
unsubscribedAfter successful connection unsubscribeconnectionId
trade_subscribedAfter successful trade subscribeconnectionId, ticker, zoomIndex
trade_unsubscribedAfter successful trade unsubscribeconnectionId, ticker
orderbook_subscribedAfter successful order book subscribeconnectionId, ticker, zoomIndex; depthLevels / depthPercent echoed when non-null, fetchSnapshot only when false
orderbook_unsubscribedAfter successful order book unsubscribeconnectionId, ticker
mark_price_subscribedAfter successful mark price subscribeconnectionId, ticker
mark_price_unsubscribedAfter successful mark price unsubscribeconnectionId, ticker
index_price_subscribedAfter successful index price subscribeconnectionId, ticker
index_price_unsubscribedAfter successful index price unsubscribeconnectionId, ticker
funding_subscribedAfter successful funding subscribeconnectionId, ticker
funding_unsubscribedAfter successful funding unsubscribeconnectionId, ticker
errorInvalid message, bad connection ID, or missing tickererror (string)

Connection-level updates:

TypeWhenData fields
order_updateOrder created/changed/filled/cancelledconnectionId, orderId, ticker, side, type, price, filledPrice, size, filledSize, fee, feeCurrency, status, time
position_updatePosition opened/changed/closedconnectionId, positionId, ticker, side, size, avgPrice, avgPriceFix, avgPriceDyn, status
balance_updateAccount balance changedconnectionId, balances[] with coin, total, free, locked
finres_updateFinancial results updatedconnectionId, finreses[] with currency, result, fee, funds, available, blocked

Market data updates:

TypeWhenData fields
trade_updateNew trades for subscribed ticker (server-side aggregated — see note below)connectionId, ticker, trades[] with price, size, side, time, highPrice, lowPrice
orderbook_snapshotFull order book on subscribeconnectionId, ticker, asks[], bids[], bestAsk, bestBid — each with price, size, type
orderbook_updateIncremental order book changesconnectionId, ticker, updates[] with price, size, type
mark_price_updateMark price changed (futures only)connectionId, ticker, markPrice
index_price_updateIndex price changed (futures only)connectionId, ticker, indexPrice
funding_updateFunding rate / time changed (perpetual futures only)connectionId, ticker, fundingRate, fundingTime (ISO 8601)

Trade aggregation: trade_update entries are aggregated server-side using the order book's AddingTicksForAPeriod setting (same value that drives the UI ticks section, default 200 ms; per-(connection, ticker)). Consecutive same-side trades within the window are merged into one entry — size is summed, price and time track the latest merged trade. A new entry is emitted when the side changes or the window expires. Each entry also carries highPrice and lowPrice — the highest and lowest price among the trades merged into that entry (the range the price travelled inside the tick), while price stays the latest merged trade. With AddingTicksForAPeriod = 0 aggregation is disabled and every entry has highPrice == lowPrice == price. Changes are picked up live by active subscriptions.

Signal levels — subscribe (globally, no data payload) to receive signal-level lifecycle events:

TypeDirectionWhenData fields
signal_level_subscribeclient → serverStart receiving signal-level events—
signal_level_unsubscribeclient → serverStop receiving them—
signal_level_subscribedserver → clientSubscribe acknowledged (a signal_levels_snapshot follows immediately)—
signal_level_unsubscribedserver → clientUnsubscribe acknowledged—
signal_levels_snapshotserver → clientCurrent levels on subscribesignalLevels[] with id, connectionId, ticker, price, isTriggered, triggerTime, triggerRule, and (MC-421) note, lineThickness, lineStyle, lineColor, textSize, textColor, textAlignment, textStyle (null when unset)
signal_level_placedserver → clientA level was createdid, connectionId, ticker, price, isTriggered, triggerTime, triggerRule, and the same MC-421 note + appearance fields
signal_level_updatedserver → clientA level was modified in place (price and/or rule changed, identity preserved)Same shape as signal_level_placed
signal_level_triggeredserver → clientA level firedid, triggerTime
signal_level_removedserver → clientA level was deletedid
signal_levels_removed_allserver → clientAll levels (for a ticker or globally) were deleted—
signal_levels_removed_triggeredserver → clientAll triggered levels were cleared—

User levels — subscribe (globally, no data payload) to receive user-level lifecycle events. date is a nullable ISO-8601 timestamp:

TypeDirectionWhenData fields
user_level_subscribeclient → serverStart receiving user-level events—
user_level_unsubscribeclient → serverStop receiving them—
user_level_subscribedserver → clientSubscribe acknowledged—
user_level_unsubscribedserver → clientUnsubscribe acknowledged—
user_levels_snapshotserver → clientCurrent levels on subscribeuserLevels[] with id, connectionId, ticker, price, name, note, date, and (MC-421) lineThickness, lineStyle, lineColor, textSize, textColor, textAlignment, textStyle (null when unset). note is a real stored field, distinct from name
user_level_placedserver → clientA level was createdid, connectionId, ticker, price, name, note, date, and the same MC-421 appearance fields
user_level_updatedserver → clientA level was modified in place (identity preserved)Same shape as user_level_placed
user_level_removedserver → clientOne or more levels were deleted (covers DELETE {id} and DELETE ?Ticker=) — removal is a batch, so ids are carried as an arrayids[]
user_levels_removed_allserver → clientAll levels (globally) were cleared—

Notifications — subscribe (globally, no data payload) to receive the notification feed (the «Line notifications» window). On subscribe the server acknowledges with notification_subscribed and pushes a one-shot notification_snapshot of the recent feed (inside the 200-row cap); after that every new row — native or injected via POST /api/notifications — is delivered live as notification_update. Without this subscribe a client never receives the notification_update the Notifications section documents:

TypeDirectionWhenData fields
notification_subscribeclient → serverStart receiving notification-feed events—
notification_unsubscribeclient → serverStop receiving them—
notification_subscribedserver → clientSubscribe acknowledged (a notification_snapshot follows immediately)—
notification_unsubscribedserver → clientUnsubscribe acknowledged—
notification_snapshotserver → clientThe recent feed on subscribe (one-shot, within the 200-row cap)notifications[] — each with connectionId, type, ticker, size, price, time and the feed cells
notification_updateserver → clientA new notification was added to the feed (covers API-injected rows)notifications[] — same shape as the snapshot rows

Chart annotations — subscribe per (connectionId, ticker) to receive the current shapes. This family delivers a subscribe/unsubscribe ack and a one-shot snapshot only — there is no annotations_updated push. The annotation service exposes no change-notification / listener mechanism, so live deltas cannot be delivered without inventing one; a client that needs fresh state after an edit re-subscribes or re-issues GET .../annotations. Datetime fields are ISO-8601.

⚠ Snapshot prices are venue prices (from the Step 14 build). The y / y1 / y2 in annotations_snapshot are the instrument's real market price (e.g. 0.2 for ADAUSDT) — identical to what GET .../annotations returns — converted per (connectionId, ticker) from that instrument's own price increment. Earlier builds passed these fields through unconverted, exposing the chart's internal axis units (×104 for ADAUSDT), so a hand-drawn ray read back at ≈1999.79 on the socket while REST already reported 0.2; the two channels now agree. The REST-side conversion shipped earlier in 1.0.2835-MC-400; the socket snapshot converts as of the Step 14 build. Timestamp fields (x1 / x2) are unchanged. If the Ticker is unknown on the connection or its price increment is ≤ 0, the server emits an error frame instead of the snapshot (a socket cannot answer 400) — it never silently returns raw axis units.

TypeDirectionWhenData fields
annotation_subscribeclient → serverStart receiving annotations for one (connectionId, ticker)connectionId, ticker
annotation_unsubscribeclient → serverStop receiving them for that (connectionId, ticker)connectionId, ticker
annotation_subscribedserver → clientSubscribe acknowledged (an annotations_snapshot follows immediately)connectionId, ticker
annotation_unsubscribedserver → clientUnsubscribe acknowledgedconnectionId, ticker
annotations_snapshotserver → clientThe current shapes on subscribe (one-shot; not re-pushed on change)connectionId, ticker, lineAnnotations[] (index, x1, x2, y1 (venue price), y2 (venue price)), horizontalLineAnnotations[] (index, y (venue price)), horizontalRayAnnotations[] (index, y (venue price), x1)

Screener stream (MC-436) — subscribe by saved screener template id (the same identity the headless GET /api/screener/templates/{templateId}/data uses; the synthetic Default template -1 is listable) to stream that screener live — no screener window required. On subscribe the server acknowledges with screener_subscribed, then delivers a screener_frame carrying the current full row set, and after that a screener_frame per backend update — full frames, no diffing, in the SAME row shape the data GET returns (ticker, wireTicker, isNewCoin, exchange, listedExchanges, columns[]). Each frame names its templateId so a client can demux multiple template subscriptions on one socket. Unsubscribe (or an abrupt disconnect) tears the backend screener session down — no leaked backend socket. Subscribe payload is { "templateId": <long> }. A duplicate screener_subscribe for the same template on the same socket is idempotent — it opens no additional backend session and answers one screener_subscribed ack. Two different clients subscribing to the same template each get their own backend session (no sharing, no pooling); tearing one down leaves the other streaming. An unknown template id yields the standard error frame (naming screener_subscribe and the offending id) and opens no stream:

TypeDirectionWhenData fields
screener_subscribeclient → serverStart streaming a screener template by idtemplateId
screener_unsubscribeclient → serverStop streaming that template (tears down the backend session)templateId
screener_subscribedserver → clientSubscribe acknowledged (a screener_frame snapshot follows immediately)templateId
screener_unsubscribedserver → clientUnsubscribe acknowledgedtemplateId
screener_frameserver → clientA full screener row set — the current snapshot on subscribe, then one per backend updatetemplateId, rows[] — each with ticker, wireTicker, isNewCoin, exchange, listedExchanges[], columns[] (type — the column number, see the list under GET /api/screener/templates/{id}/data (MET-1833); timeFrame, time, metric, value; fundingRate is scaled ×100 as in the window)

Density map stream (MET-1403) — the MetaBroker screener.v2 density map (orderbook walls) as a notifications feed: each wall becomes exactly ONE notification, on first sight of its id — the same semantics the terminal's Density Map window shows (a wall the feed removes and later re-broadcasts under the same id is shown again in the window, as on the MetaBroker page; on this API an id is delivered at most once per subscription, in the snapshot or in an update). The subscribe data carries the feed config; only exchangeMarkets is required (an empty list is rejected — upstream it means "nothing"), everything else defaults to the window's defaults: coefficients 3/2/1, lifetimes 5 min, includedQuoteAssets ["USDT"] (valid assets: USDT, USDC, OTHER). Each exchangeMarkets[] entry is { "exchange", "market" } — enums accept names or numbers (exchange: binance, gate, bybit, kucoin, bitget, mexc, okx, bingx, htx, bitmart, lbank, hyperliquid, upbit, asterdex, lighter, xt, edgex, bitunix, ourbit, whitebit, blofin, weex, polymarket; market: spot | futures). The base density size of the density map is always auto (MetaBroker MB-702 removed the manual mode): bdsMode: "manual" or a bdsValue in an entry is refused with an error frame naming the field (MET-1828); bdsMode: "auto" is accepted as a no-op. These two fields belong to large_trades_subscribe, where they apply. On subscribe the server acks with density_map_subscribed, then delivers a density_map_snapshot (may be empty — it resolves the loading state), then density_map_update batches. A repeat density_map_subscribe on the same socket replaces the config (upstream holds one density subscription per connection) and is answered with density_map_subscribed plus a fresh density_map_snapshot of the walls that match the new filters, without replaying already-notified walls as updates (MET-1803); each socket gets its own upstream feed, so API clients and the Density Map window never affect each other. Unsubscribe (or an abrupt disconnect) tears the upstream socket down:

TypeDirectionWhenData fields
density_map_subscribeclient → serverStart (or reconfigure) the density notifications stream — a repeat subscribe re-sends the snapshot for the new filtersexchangeMarkets[] (required), largeCoefficient, mediumCoefficient, smallCoefficient, largeLifetimeMinutes, mediumLifetimeMinutes, smallLifetimeMinutes, includedQuoteAssets[]
density_map_unsubscribeclient → serverStop the stream (send "data": {})—
density_map_subscribedserver → clientSubscribe acknowledged (a density_map_snapshot follows)—
density_map_unsubscribedserver → clientUnsubscribe acknowledged—
density_map_snapshotserver → clientFirst-seen walls of the initial upstream snapshot; forwarded even when emptynotifications[] — each with id, exchange, exchangeLogo, market (spot | futures), ticker, side (ask | bid), price, distancePercent, sizeUsd, time
density_map_updateserver → clientNewly seen walls (one notification per wall id, ever)same notifications[] shape

Large trades stream (MET-1403) — the MetaBroker screener.v2 large trades feed (aggregated trade prints). No snapshot exists upstream — rows are final, append-only aggregates and history starts at subscribe time. The subscribe data uses the same building blocks as the density stream (exchangeMarkets[] required, same enum names) plus the aggregation window: aggregationMs (0–60000, default 500 — out of range is refused with an error frame naming the field; 0 = every raw print individually) and an optional minAmountUsd floor (applied by the terminal on top of the venue-side BDS threshold: a row is sent only when it passes both — MET-1832); defaults are the window's: coefficients 3/2/1, includedQuoteAssets ["USDT","USDC","OTHER"]. A repeat large_trades_subscribe replaces the config; if the upstream feed rejects a subscription after the ack, the standard error frame names large_trades_subscribe while the feed retries itself:

TypeDirectionWhenData fields
large_trades_subscribeclient → serverStart (or reconfigure) the large trades streamexchangeMarkets[] (required), aggregationMs, minAmountUsd, largeCoefficient, mediumCoefficient, smallCoefficient, includedQuoteAssets[]
large_trades_unsubscribeclient → serverStop the stream (send "data": {})—
large_trades_subscribedserver → clientSubscribe acknowledged (no snapshot — rows follow as they happen)—
large_trades_unsubscribedserver → clientUnsubscribe acknowledged—
large_trades_updateserver → clientFinished aggregates, batched (~100 ms upstream tick); append-only, never updated or removedtrades[] — each with id, exchange, exchangeLogo, market (spot | futures), ticker, side (buy | sell), minPrice, maxPrice, sizeUsd, tradeCount, category (small | medium | large), time

Liquidations stream (MET-1403) — the MetaBroker cross-exchange liquidations feed (futures only). No MetaBroker login is needed: the upstream is the screener-v2 hub signed with the shared service key, the same connection the density map and large trades streams use. The subscribe data: exchanges[] (required, names or numbers: binance, bybit, okx, bitget, gate, htx, aster, lighter), minNotionalUsd (default 0), minImpactBps (default off; rows with unknown turnover are dropped when set), assetClass (all | crypto | tradfi, default all — a row's class is the one the Liquidations window shows for its coin, taken from the backend's clusters feed because the per-event tape carries none (MET-1829); a live row of a coin first seen in the session waits up to 2 s for that class before it is sent), side (all | long | short — side of the liquidated position, default all), coin (case-insensitive prefix on the resolved coin, e.g. "BTC"), window (m5 | m15 | h1 | h4 | h24, default h1 — affects totals/topTokens only), backfill (0–500 snapshot rows, default 200; out of range is refused with an error frame naming the field). On subscribe the server acks with liquidations_subscribed, then delivers a liquidations_snapshot; a repeat liquidations_subscribe replaces the filters and the server re-sends a snapshot. Live rows ride liquidations_update batches and the header aggregates ride a periodic liquidations_metadata:

TypeDirectionWhenData fields
liquidations_subscribeclient → serverStart (or reconfigure) the liquidations stream — no MetaBroker login neededexchanges[] (required), minNotionalUsd, minImpactBps, assetClass, side, coin, window, backfill
liquidations_unsubscribeclient → serverStop the stream (send "data": {})—
liquidations_subscribedserver → clientSubscribe acknowledged (a liquidations_snapshot follows)—
liquidations_unsubscribedserver → clientUnsubscribe acknowledged—
liquidations_snapshotserver → clientBackfill rows + aggregates, after every subscribe/reconfigure; may be empty (it resolves the loading state)liquidations[] — each with time, exchange, symbol, coin, side (long | short), assetClass (crypto | tradfi), price, size, notionalUsd, impactBps (nullable); plus totals (longUsd, shortUsd, longCount, shortCount; nullable) and topTokens[] (token + the same four fields)
liquidations_updateserver → clientLive rows, batched (~250 ms upstream tick), newest firstliquidations[] — same row shape
liquidations_metadataserver → clientAggregates refresh on every upstream map tick (sub-second) for the configured windowtotals, topTokens[]

UI change events — subscribe (globally, no data payload) to observe the open UI. On subscribe the server acknowledges with ui_subscribed and immediately pushes a ui_snapshot whose Data is identical to GET /api/ui/state (same camelCase shape). After that, a ui_update is pushed per observed change. Read-only — the server never opens, closes or mutates any UI element here:

TypeDirectionWhenData fields
ui_subscribeclient → serverStart receiving UI change events (send "data": {})—
ui_unsubscribeclient → serverStop receiving them—
ui_subscribedserver → clientSubscribe acknowledged (a ui_snapshot follows immediately)—
ui_unsubscribedserver → clientUnsubscribe acknowledged—
ui_snapshotserver → clientThe whole open UI on subscribeSame shape as GET /api/ui/state: windows[], standaloneCharts[]
ui_updateserver → clientOne observed UI changekind plus a kind-specific payload — see below

ui_update payloads by kind:

kindWhenData fields
tabAddedA tab was added to a windowkind, windowId, window (the full window subtree, same shape as one entry of state.windows[])
documentOpenedA chart document was openedkind, documentId, externalId, connectionId (null when unbound), ticker, document (same shape as a state document, kind: "chart")

Coverage — MetaScalp.Api observes only Application-layer events (it does not reference the WPF UI project), so this rung delivers exactly the two ui_update kinds above. The following changes are not emitted yet (no reachable Application-layer event publishes them today, and no polling is used): order-book document opened, ticker changed, window opened/closed, tab removed/activated, document closed/moved, and link-number changes. Clients that need the current state after such a change can re-request GET /api/ui/state.

Lifecycle

EventBehavior
Client connectsSession created. No updates until subscribe.
Subscribe (valid ID)Server responds with subscribed. Order, position, balance, finres updates start flowing.
Subscribe (invalid ID)Server responds with error. No subscription created.
Subscribe (already subscribed)Idempotent — responds with confirmation, no duplicate events.
Trade/orderbook subscribe (valid)Server responds with trade_subscribed / orderbook_subscribed. Market data starts flowing for that ticker.
Trade/orderbook subscribe (invalid)Server responds with error (bad connection ID or missing ticker).
Mark price / funding subscribe (valid)Server responds with mark_price_subscribed / funding_subscribed. Updates flow as the exchange publishes them (no initial snapshot).
Mark price / funding subscribe (invalid)Server responds with error (bad connection ID or missing ticker).
UnsubscribeServer responds with confirmation. Updates stop for that subscription.
Client disconnectsAll subscriptions (connection-level and market data) are cleaned up automatically.
Multiple connectionsA single client can subscribe to multiple connection IDs and multiple tickers simultaneously.

Example payloads

// Subscribe
{ "Type": "subscribe", "Data": { "connectionId": 1 } }

// Server confirms
{ "Type": "subscribed", "Data": { "connectionId": 1 } }

// Order update pushed by server
{
  "Type": "order_update",
  "Data": {
    "connectionId": 1,
    "orderId": 98765,
    "ticker": "BTCUSDT",
    "side": "Buy",
    "type": "Limit",
    "price": 65000.0,
    "filledPrice": 0.0,
    "size": 0.01,
    "filledSize": 0.0,
    "fee": 0.0,
    "feeCurrency": "USDT",
    "status": "New",
    "time": "2026-03-13T10:30:00+00:00"
  }
}

// Position update pushed by server
{
  "Type": "position_update",
  "Data": {
    "connectionId": 1,
    "positionId": 4321,
    "ticker": "ETHUSDT",
    "side": "Buy",
    "size": 1.5,
    "avgPrice": 3200.00,
    "avgPriceFix": 3200.00,
    "avgPriceDyn": 3200.00,
    "status": "Open"
  }
}

// Balance update pushed by server
{
  "Type": "balance_update",
  "Data": {
    "connectionId": 1,
    "balances": [
      { "coin": "USDT", "total": 10000.0, "free": 8500.0, "locked": 1500.0 },
      { "coin": "BTC", "total": 0.5, "free": 0.5, "locked": 0.0 }
    ]
  }
}

// FinRes update pushed by server
{
  "Type": "finres_update",
  "Data": {
    "connectionId": 1,
    "finreses": [
      { "currency": "USDT", "result": 250.50, "fee": 12.30, "funds": 10000.0, "available": 8500.0, "blocked": 1500.0 },
      { "currency": "BTC", "result": 0.005, "fee": 0.0001, "funds": 0.5, "available": 0.5, "blocked": 0.0 }
    ]
  }
}

// Subscribe to trades for a specific ticker (zoomIndex is optional)
{ "Type": "trade_subscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT", "zoomIndex": 1 } }

// Server confirms trade subscription
{ "Type": "trade_subscribed", "Data": { "connectionId": 1, "ticker": "BTCUSDT", "zoomIndex": 1 } }

// Trade update pushed by server
{
  "Type": "trade_update",
  "Data": {
    "connectionId": 1,
    "ticker": "BTCUSDT",
    "trades": [
      { "price": 65123.50, "size": 0.15, "side": "Buy", "time": "2026-03-16T12:00:01.234+00:00", "highPrice": 65124.00, "lowPrice": 65123.00 },
      { "price": 65123.00, "size": 0.03, "side": "Sell", "time": "2026-03-16T12:00:01.235+00:00", "highPrice": 65123.00, "lowPrice": 65123.00 }
    ]
  }
}

// Subscribe to order book for a specific ticker (zoomIndex / depthLevels / depthPercent / fetchSnapshot are optional; fetchSnapshot defaults to true)
{ "Type": "orderbook_subscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT", "zoomIndex": 0, "depthLevels": 50, "depthPercent": 0.5, "fetchSnapshot": true } }

// Server confirms order book subscription (echoes any non-null depth params; fetchSnapshot only when false)
{ "Type": "orderbook_subscribed", "Data": { "connectionId": 1, "ticker": "BTCUSDT", "zoomIndex": 0, "depthLevels": 50, "depthPercent": 0.5 } }

// Order book snapshot (full state, sent once on subscribe)
{
  "Type": "orderbook_snapshot",
  "Data": {
    "connectionId": 1,
    "ticker": "BTCUSDT",
    "asks": [
      { "price": 65124.00, "size": 1.20, "type": "Ask" },
      { "price": 65125.00, "size": 0.85, "type": "Ask" }
    ],
    "bids": [
      { "price": 65123.00, "size": 2.50, "type": "Bid" },
      { "price": 65122.00, "size": 1.10, "type": "Bid" }
    ],
    "bestAsk": { "price": 65124.00, "size": 1.20, "type": "BestAsk" },
    "bestBid": { "price": 65123.00, "size": 2.50, "type": "BestBid" }
  }
}

// Order book incremental update
{
  "Type": "orderbook_update",
  "Data": {
    "connectionId": 1,
    "ticker": "BTCUSDT",
    "updates": [
      { "price": 65124.00, "size": 0.90, "type": "Ask" },
      { "price": 65126.00, "size": 0.50, "type": "Ask" },
      { "price": 65123.00, "size": 2.80, "type": "Bid" }
    ]
  }
}

// Subscribe to mark price for a specific ticker
{ "Type": "mark_price_subscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }

// Server confirms mark price subscription
{ "Type": "mark_price_subscribed", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }

// Mark price update pushed by server
{ "Type": "mark_price_update", "Data": { "connectionId": 1, "ticker": "BTCUSDT", "markPrice": 65123.5 } }

// Subscribe to funding for a specific ticker (perpetual futures only)
{ "Type": "funding_subscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }

// Server confirms funding subscription
{ "Type": "funding_subscribed", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }

// Funding update pushed by server
{ "Type": "funding_update", "Data": { "connectionId": 1, "ticker": "BTCUSDT", "fundingRate": 0.0001, "fundingTime": "2026-03-16T16:00:00+00:00" } }

// Unsubscribe from market data
{ "Type": "trade_unsubscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }
{ "Type": "orderbook_unsubscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }
{ "Type": "mark_price_unsubscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }
{ "Type": "funding_unsubscribe", "Data": { "connectionId": 1, "ticker": "BTCUSDT" } }

// Unsubscribe from connection
{ "Type": "unsubscribe", "Data": { "connectionId": 1 } }

// Error (invalid connection)
{ "Type": "error", "Data": { "error": "Connection 999 not found or not active" } }

Reference

Exchange values

ValueExchange
2Binance
3GateIo
5KuCoin
6Bybit
7Bitget
8Mexc
10Okx
11BingX
12HTX
13BitMart
14LBank
15HyperLiquid
16UpBit
17AsterDex
18Moex
19Lighter

MarketType values

ValueTypeDescription
0SpotSpot trading
1FuturesGeneric futures
2UsdtFuturesUSDT-margined futures
3CoinFuturesCoin-margined futures
4InverseFuturesInverse futures
5UsdtPerpetualUSDT perpetual swaps
6UsdcPerpetualUSDC perpetual swaps
7MarginMargin (cross/isolated)
8OptionsOptions contracts
9StockStock / equity markets
Unsure which market type to use? Use the ticker pattern approach instead — the .p suffix auto-resolves to the right futures type. For explicit fields, 2 (UsdtFutures) is the most common choice for perpetual futures.
When the requested market is not Spot and no exact match is found, MetaScalp falls back to any non-Spot connection on the same exchange.

Error responses

Errors return HTTP 400 or 404 with a JSON body.

Request body parsing (MC-421 Step 15, corrected in Step 16) — applies to every endpoint with a JSON body: a body that is not valid JSON returns 400 immediately. A body carrying an unknown property is rejected with 400 naming the offending field (previously the unknown property was silently discarded and the request answered 200). Two endpoint families answer with their own fixed wording instead of the shared text and are unchanged: /api/ui/windows/{windowId}/close and /activate answer Malformed request body., and the screener-template routes answer Request body is not valid JSON..

⚠ Breaking change for clients that send extra fields. Before this change an unrecognised property was accepted and dropped. It is now a 400, on every body endpoint including POST /api/connections/{id}/orders and the cancel routes — so an SDK or script that sends a field this reference does not list will stop working and no order will be placed. Send only the documented fields.

Read-modify-write is supported. The identity and server-owned fields that a GET emits are accepted and ignored on the matching PUT/POST, so a client can send back the object it just read: id, connectionId, ticker on user levels; those plus isTriggered and triggerTime on signal levels; id on screener templates; index on all three annotation types. A value supplied in one of these fields never re-keys, moves or re-triggers anything — the route parameters remain authoritative. Two known exceptions that are not round-trippable: a user level whose date is non-null (GET emits ISO-8601, PUT takes raw epoch seconds — a deliberate asymmetry that predates this change), and the order-book settings PUT, which takes the bare settings object rather than the {connectionId, ticker, settings} envelope that GET returns.

EndpointConditionHTTPError message
(any endpoint with a JSON body)Body is not valid JSON400Malformed request body: not valid JSON.
(any endpoint with a JSON body)Unknown property in body400Unknown field '{field}' in request body.
/api/change-tickerMissing fields400Invalid request body. Provide 'tickerPattern' or 'exchange'+'market'+'ticker'.
/api/change-tickerInvalid pattern400Invalid ticker pattern: '{pattern}'
/api/change-tickerBinding not found400Binding '{name}' not found. Available: {list}
/api/change-tickerNo connection400No connection found for exchange ... and market ...
/api/change-tickerTicker not on connection400Ticker '{ticker}' not found on connection {id}
/api/comboMissing ticker400Invalid request body. 'ticker' is required.
/api/connections/{id}Connection not found404Connection {id} not found
/api/connections/{id}/...Invalid connection ID400Invalid connection ID
/api/connections/{id}/...Connection not found404Connection {id} not found
/api/connections/{id}/...Connection not active400Connection {id} is not active
/api/connections/{id}/ordersMissing ticker query param400Query parameter 'Ticker' is required
/api/connections/{id}/ordersInvalid order fields400Invalid request body. 'ticker', 'side', 'price', and 'size' are required.
/api/connections/{id}/ordersSize <= 0400Size must be greater than zero
/api/connections/{id}/ordersPrice <= 0 (non-market)400Price must be greater than zero for non-market orders
/api/connections/{id}/orders/cancelMissing fields400Invalid request body. 'ticker' and 'orderId' are required.
POST /api/connections/{id}/signal-levelsMissing ticker/price400Invalid request body. 'ticker' and 'price' are required.
POST/PUT .../signal-levelsPrice <= 0400Price must be greater than zero
POST/PUT .../signal-levelsUnknown triggerRule400Invalid 'triggerRule'. Allowed values: LessThanEqual, GreaterThanEqual.
POST /api/connections/{id}/signal-levelsNo order book data400No market data for '{ticker}'. Subscribe to order book data for this ticker first.
PUT .../signal-levels/{signalLevelId}Invalid signal level ID400Invalid signal level ID
PUT .../signal-levels/{signalLevelId}Empty body / all fields null400Request body must set at least one of 'price' or 'triggerRule'.
PUT .../signal-levels/{signalLevelId}Level not found404Signal level {id} not found
POST /api/connections/{id}/user-levelsMissing ticker/price400Invalid request body. 'ticker' and 'price' are required.
POST/PUT .../user-levelsPrice <= 0400Price must be greater than zero
PUT/DELETE .../user-levels/{userLevelId}Invalid user level ID400Invalid user level ID
PUT .../user-levels/{userLevelId}Empty body / all fields null400Request body must set at least one of 'price', 'name', 'date', 'note' or an appearance field.
PUT .../user-levels/{userLevelId}Level not found404User level {id} not found
GET/DELETE .../user-levelsMissing Ticker query400Query parameter 'Ticker' is required
POST /api/notificationsUnknown connectionId404Connection {id} not found
GET /api/ui/windows/{windowId}Non-numeric window id400Invalid window ID
GET /api/ui/windows/{windowId}Unknown window404Window {id} not found
POST /api/ui/windowsMalformed body400Malformed request body.
POST /api/ui/windowsMissing windowType400POST /api/ui/windows requires 'windowType' in the body to know which window to open. (+ supported list)
POST /api/ui/windows/{windowId}/close|activateMissing windowType400POST /api/ui/windows/{windowId}/close|activate requires 'windowType' in the body: window ids are per-surface, so the type is what routes the id to the right window. (+ supported list)
POST /api/ui/windowsUnknown / unsupported-standalone type400Unsupported windowType '…'. (+ supported list)
POST /api/ui/windowsUnknown screener templateId404Screener template {templateId} not found
PUT /api/ui/documents/{externalId}/link-numberNon-GUID document id400Invalid document ID
PUT /api/ui/documents/{externalId}/link-numberMissing linkNumber400Request body must set 'linkNumber'.
PUT /api/ui/documents/{externalId}/link-numberNon-positive linkNumber400linkNumber must be a positive binding-group id (1-500, or 9999 to cancel).
PUT /api/ui/documents/{externalId}/link-numberUnknown document404Document {externalId} not found
PUT /api/ui/documents/{externalId}/tickerNon-GUID document id400Invalid document ID
PUT /api/ui/documents/{externalId}/tickerMissing ticker400Request body must set 'ticker'.
PUT /api/ui/documents/{externalId}/tickerNo connection to inherit400connectionId is required: the document has no current connection to inherit.
PUT /api/ui/documents/{externalId}/tickerUnknown document404Document {externalId} not found
anyUnknown path400Not found
anyWrong HTTP method400Method not allowed

Socket error messages

Socket errors are sent as { "Type": "error", "Data": { "error": "..." } }

ConditionError message
Subscribe to non-existent connectionConnection {id} not found or not active
Trade/orderbook subscribe with missing tickerTicker is required for trade subscription / Ticker is required for order book subscription
Trade/orderbook subscribe with invalid connectionConnection {id} not found or not active
Mark price / funding subscribe with missing tickerTicker is required for mark price subscription / Ticker is required for funding subscription
Mark price / funding subscribe with invalid connectionConnection {id} not found or not active
Unknown message typeUnknown message type: {type}
Invalid JSONInvalid message format

📄 MetaScalp-Api.md

⬇ Download .md

MetaScalp API

MetaScalp exposes a local API that lets your scripts and bots interact with connected exchanges. Use HTTP REST endpoints for request/response operations and WebSocket for real-time streaming of orders, positions, balances, trades, and order book data.

  • Host: 127.0.0.1 (localhost only)
  • HTTP port range: 17845–17855 (first available)
  • WebSocket: Same port as HTTP (upgrade requests accepted automatically)
  • Content-Type: application/json
  • CORS: All responses include Access-Control-Allow-Origin: *.

Overview

HTTP REST endpoints

Use HTTP to discover connections, query data, and execute trades:

EndpointPurpose
GET /pingFind the running MetaScalp instance and check its version
POST /api/change-tickerSwitch the active ticker in the MetaScalp UI
POST /api/comboOpen a combo layout for a ticker
GET /api/connectionsList all active exchange connections
GET /api/connections/{id}/...Query tickers, orders, positions, balances for a connection
POST /api/connections/{id}/ordersPlace or cancel orders on a connection
POST /api/connections/{connectionId}/orders/cancel-allCancel all open orders for a ticker on a connection (body: { ticker }) → { status, cancelledCount }
GET /api/connections/{connectionId}/orderbook-snapshotREST order-book snapshot for a ticker (optional DepthLevels / DepthPercent; 501 when the exchange has no REST snapshot)
GET /api/link-groups/{groupId}/orderbook-snapshotsRead-only order books of every panel currently in link group N (live membership; per-entry reason on partial failure)
GET/PUT /api/connections/{connectionId}/orderbook-settingsRead, and partially update (every field optional), the order-book settings set for a ticker — changes are pushed live to the running panels. Working volumes are stored as paired USD/coin mirrors (tradeAmountUsd1..5 ↔ tradeAmount1..5): writing one side recomputes the other at the live best-ask (truncated to the ticker's size increment, floored at its min size), so the terminal's own volume buttons show USD and coin figures that agree with the current price. Writing both sides of a pair keeps the values you sent. If no order book is open for the connection+ticker there is no live price → 409 (open the order book so the conversion has a live price); unknown ticker → 404; nothing is written in either case.
GET /api/connections/{connectionId}/cluster-snapshotRemote cluster (footprint) snapshot for a ticker at a timeframe (optional ZoomIndex, Columns = history depth up to 100)
GET/POST/PUT/DELETE /api/connections/{id}/signal-levelsRead, create, modify (in place) and delete signal levels for a ticker
DELETE /api/signal-levels/triggeredRemove all triggered signal levels (across every connection)
GET/POST/PUT/DELETE /api/connections/{id}/user-levelsRead, create, modify (in place) and delete user (plain) levels for a ticker
GET/PUT/POST/DELETE /api/connections/{id}/annotationsRead all three chart-annotation lists, replace a list (PUT), append one (POST) or delete by index / clear all — addressed by zero-based list index
POST /api/notificationsInject a custom row into the notification feed (all fields optional, arbitrary event type)
GET /api/ui/stateFull read-only inventory of the open UI: windows, tabs, order-book/chart documents and standalone charts (rebuilt from the saved layout)
GET /api/ui/windows/{windowId}The same inventory object for one window; unknown id → 404
POST /api/ui/windowsOpen a terminal window at a requested place/size (and monitor); omitted geometry → today's default placement
PUT /api/ui/documents/{externalId}/link-numberSet one panel's link (binding-group) number; unknown document → 404
PUT /api/ui/documents/{externalId}/tickerRe-point one addressed panel to a different market (that panel only)

WebSocket streaming

Connect via WebSocket to receive real-time updates for your exchange connections.

  • Connect to ws://127.0.0.1:{port}/ (same port as HTTP — scan 17845–17855)
  • Connection-level: Send subscribe with a connection ID to receive order, position, balance, and finres updates
  • Market data: Send trade_subscribe, orderbook_subscribe, mark_price_subscribe, or funding_subscribe with connection ID + ticker to receive trade, order book, mark price, or funding updates
  • You can subscribe to multiple connections and tickers simultaneously
  • All subscriptions are automatically cleaned up when you disconnect

Getting started

1. Launch MetaScalp — both HTTP and WebSocket servers start automatically.

2. Discover the port — scan 17845–17855 with GET /ping.

3. List connections — call GET /api/connections to see available exchange connections.

4. Execute operations — use a connection ID for REST queries or WebSocket subscriptions.

Typical REST client flow

GET /ping                                         → find MetaScalp
GET /api/connections                              → list active connections
GET /api/connections/{id}/tickers                 → get available tickers
GET /api/connections/{id}/balance                 → check balances
GET /api/connections/{id}/orders?Ticker=BTCUSDT   → view open orders
POST /api/connections/{id}/orders                 → place an order
POST /api/connections/{id}/orders/cancel          → cancel an order

Typical WebSocket client flow

1. Connect
ws = new WebSocket("ws://127.0.0.1:17845/")

2. Subscribe to a connection (orders, positions, balances, finres)
ws.send('{"Type":"subscribe","Data":{"connectionId":1}}')
← {"Type":"subscribed","Data":{"connectionId":1}}

3. Subscribe to market data for a specific ticker
ws.send('{"Type":"trade_subscribe","Data":{"connectionId":1,"ticker":"BTCUSDT"}}')
← {"Type":"trade_subscribed","Data":{"connectionId":1,"ticker":"BTCUSDT"}}
ws.send('{"Type":"orderbook_subscribe","Data":{"connectionId":1,"ticker":"BTCUSDT"}}')
← {"Type":"orderbook_subscribed","Data":{"connectionId":1,"ticker":"BTCUSDT"}}

4. Receive real-time updates
← {"Type":"order_update","Data":{"connectionId":1,"orderId":123,...}}
← {"Type":"position_update","Data":{"connectionId":1,...}}
← {"Type":"balance_update","Data":{"connectionId":1,"balances":[...]}}
← {"Type":"finres_update","Data":{"connectionId":1,"finreses":[...]}}
← {"Type":"trade_update","Data":{"connectionId":1,"ticker":"BTCUSDT","trades":[...]}}
← {"Type":"orderbook_snapshot","Data":{"connectionId":1,"ticker":"BTCUSDT","asks":[...],"bids":[...],...}}
← {"Type":"orderbook_update","Data":{"connectionId":1,"ticker":"BTCUSDT","updates":[...]}}

5. Unsubscribe when done
ws.send('{"Type":"trade_unsubscribe","Data":{"connectionId":1,"ticker":"BTCUSDT"}}')
ws.send('{"Type":"orderbook_unsubscribe","Data":{"connectionId":1,"ticker":"BTCUSDT"}}')
ws.send('{"Type":"unsubscribe","Data":{"connectionId":1}}')
← {"Type":"unsubscribed","Data":{"connectionId":1}}

WebSocket message reference

Messages you send

TypeDataDescription
subscribe{ "connectionId": 123 }Start receiving order, position, balance, finres updates for a connection.
unsubscribe{ "connectionId": 123 }Stop receiving connection-level updates.
trade_subscribe{ "connectionId": 123, "ticker": "BTCUSDT", "zoomIndex": 1 }Start receiving trade updates for a specific ticker. Optional zoomIndex > 1 aggregates by zoomed price.
trade_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving trade updates for that ticker.
orderbook_subscribe{ "connectionId": 123, "ticker": "BTCUSDT", "fetchSnapshot": true }Start receiving order book snapshot + incremental updates for a specific ticker. Optional zoomIndex, depthLevels, depthPercent; optional fetchSnapshot (default true) — set to false to skip the exchange REST snapshot fetch on cold subscribe, useful for mass-subscribing many tickers without hitting REST rate limits.
orderbook_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving order book updates for that ticker.
mark_price_subscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Start receiving mark price updates for a specific ticker (no initial snapshot).
mark_price_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving mark price updates for that ticker.
funding_subscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Start receiving funding rate updates (perpetual futures only).
funding_unsubscribe{ "connectionId": 123, "ticker": "BTCUSDT" }Stop receiving funding updates for that ticker.
density_map_subscribe{ "exchangeMarkets": [{ "exchange": "binance", "market": "futures" }], "includedQuoteAssets": ["USDT"] }Start the MetaBroker density-wall notifications stream. exchangeMarkets is required; optional coefficients (3/2/1), lifetimes (5 min) and quote assets. Re-subscribing replaces the config.
density_map_unsubscribe{}Stop the density map stream.
large_trades_subscribe{ "exchangeMarkets": [{ "exchange": "binance", "market": "futures" }], "aggregationMs": 500, "minAmountUsd": 100000 }Start the MetaBroker aggregated large-trades stream (no snapshot — rows are final and append-only). Re-subscribing replaces the config.
large_trades_unsubscribe{}Stop the large trades stream.
liquidations_subscribe{ "exchanges": ["binance", "bybit", "okx"], "minNotionalUsd": 1000, "window": "h1", "backfill": 200 }Start the MetaBroker cross-exchange liquidations stream (futures only). No MetaBroker login is needed. Re-subscribing replaces the filters and replays a snapshot.
liquidations_unsubscribe{}Stop the liquidations stream.

Messages you receive

TypeData fields
subscribedconnectionId
unsubscribedconnectionId
trade_subscribedconnectionId, ticker, zoomIndex
trade_unsubscribedconnectionId, ticker
orderbook_subscribedconnectionId, ticker, zoomIndex (+ depthLevels / depthPercent when non-null, fetchSnapshot when false)
orderbook_unsubscribedconnectionId, ticker
order_updateconnectionId, orderId, ticker, side, type, price, filledPrice, size, filledSize, fee, feeCurrency, status, time
position_updateconnectionId, positionId, ticker, side, size, avgPrice, avgPriceFix, avgPriceDyn, status
balance_updateconnectionId, balances[]: coin, total, free, locked
finres_updateconnectionId, finreses[]: currency, result, fee, funds, available, blocked
trade_updateconnectionId, ticker, trades[]: price, size, side, time, highPrice, lowPrice
orderbook_snapshotconnectionId, ticker, asks[], bids[], bestAsk, bestBid — each: price, size, type
orderbook_updateconnectionId, ticker, updates[]: price, size, type
mark_price_subscribedconnectionId, ticker
mark_price_unsubscribedconnectionId, ticker
mark_price_updateconnectionId, ticker, markPrice
funding_subscribedconnectionId, ticker
funding_unsubscribedconnectionId, ticker
funding_updateconnectionId, ticker, fundingRate, fundingTime (ISO 8601)
signal_levels_snapshotsignalLevels[]: id, connectionId, ticker, price, isTriggered, triggerTime, triggerRule
signal_level_placedid, connectionId, ticker, price, isTriggered, triggerTime, triggerRule
signal_level_updatedSame shape as signal_level_placed (level modified in place)
signal_level_triggeredid, triggerTime
signal_level_removedid
user_levels_snapshotuserLevels[]: id, connectionId, ticker, price, name, note, date
user_level_placedid, connectionId, ticker, price, name, note, date
user_level_updatedSame shape as user_level_placed (level modified in place)
user_level_removedids[] (batch removal)
user_levels_removed_all—
annotation_subscribedconnectionId, ticker
annotation_unsubscribedconnectionId, ticker
annotations_snapshotconnectionId, ticker, lineAnnotations[]: index, x1, x2, y1, y2; horizontalLineAnnotations[]: index, y; horizontalRayAnnotations[]: index, y, x1 — y/y1/y2 are venue prices (same as GET; unresolvable ticker → error frame) (no annotations_updated — snapshot is one-shot)
density_map_subscribed— (a density_map_snapshot follows immediately)
density_map_unsubscribed—
density_map_snapshotnotifications[]: id, exchange, exchangeLogo, market (spot | futures), ticker, side (ask | bid), price, distancePercent, sizeUsd, time — may be empty (it resolves the loading state)
density_map_updateSame shape as density_map_snapshot (newly seen walls — one notification per wall id, ever)
large_trades_subscribed— (no snapshot — updates follow as trades happen)
large_trades_unsubscribed—
large_trades_updatetrades[]: id, exchange, exchangeLogo, market (spot | futures), ticker, side (buy | sell), minPrice, maxPrice, sizeUsd, tradeCount, category (small | medium | large), time — append-only, never updated or removed
liquidations_subscribed— (a liquidations_snapshot follows immediately)
liquidations_unsubscribed—
liquidations_snapshotliquidations[]: time, exchange, symbol, coin, side (long | short), assetClass (crypto | tradfi), price, size, notionalUsd, impactBps (nullable); totals: longUsd, shortUsd, longCount, shortCount (nullable); topTokens[]: token + the same four totals fields
liquidations_updateliquidations[] — same row shape (batched, newest first)
liquidations_metadatatotals, topTokens[] — aggregates refresh on every upstream map tick (sub-second) for the configured window
errorerror (string message)

For complete endpoint documentation with request/response details, field descriptions, and integration examples, see the interactive panels above or download the Markdown file.