Skip to content
REST · one port · no account

One envelope, every route

The engine serves REST, WebSocket, MCP and the CLI from a single process on http://127.0.0.1:8420. Every response is the same envelope, market data is organised strictly by market type, and webhook subscriptions are global. The same port also serves what the engine computes on that data indicators, market structure, strategies, backtests, a trade journal and scored market events. There is no account and no API key: the loopback bind is the authentication model, and scopes are the authorization one.

Path+verb routes
104

54 global · 25 × 2 market-typed

Venue operations
23

7 spot · 16 futures, behind one route

Overview

Two market types, three shapes of request, one envelope. Everything below is reachable the moment the engine is running there is nothing to provision and nothing to authorise.

Market data routes are namespaced by market type: /api/spot/… and /api/futures/…. Everything else health, configuration, subscriptions, the event log is global, because a subscription rule carries its own market type and would be contradicted by a second one in the path.

Nothing is tracked until you ask. An idle engine holds a pair catalogue and no streams. Favorite a pair and the engine opens what it needs for that pair; remove it and the streams close. Chart and OHLCV routes are the two exceptions they work on any catalogued pair, favorited or not, because both can backfill.

The engine computes, it does not only relay. Indicators, derived market structure, orderflow metrics, strategy documents, backtests, a trade journal and scored market events are all first-class parts of this API, grouped into 5 feature modules. Which of them a build carries is reported by the capabilities route, so a client discovers the surface instead of assuming it.

# everything the engine knows about one pair, price-stampedcurl -s http://127.0.0.1:8420/api/spot/context/binance/BTCUSDT# narrow it to one halfcurl -s 'http://127.0.0.1:8420/api/spot/context/binance/BTCUSDT?include=orderflow'
{ "ok": true, "data": { … } }
One request, one price-stamped read of the pair. Favorited pairs only.

Security boundary

Local-first, not authenticatedbinds 127.0.0.1

The bind address is the security boundary

There is no account, no signup, no API key and no session. The engine is a process on your machine, and it binds to 127.0.0.1:8420. Anything that can reach the port has every route on this page, including the exchange passthrough and the subscription routes.

Treat the loopback interface as the whole of the access-control model. Do not move the bind to 0.0.0.0, onto a LAN interface, or behind a public reverse proxy. If you need the surface from another machine, tunnel it — ssh -L rather than exposing it.

Browsers get a narrower door than curl does. A request that carries an Origin header has to name an allowed origin, so a page you happen to have open in another tab is not a working client for your engine. A request with no origin curl, a server-side job, the MCP surface is unaffected. The engine can also be started behind a shared token, in which case every request carries it as X-Engine-Token and an unauthenticated call answers 401 unauthorized.

What is never returned

Exchange credential values are masked wherever configuration is echoed, and anything an exchange sends back is scrubbed before it reaches a response, a log line or an error message. A webhook signing secret is write-only in the strictest sense: no surface returns it, nothing logs it, and a subscription record reports only whether one is set.

Authorization, not authenticationX-PS-Scopes

Nothing signs in, and a caller can still be given less

Authentication and authorization are separate questions, and only the first is answered by the bind address. There is still no account to sign into. There is, however, a capability vocabulary of 19 scopes, and every request is checked against it. A caller begins with all of them and can only ever end up with fewer.

Narrowing happens in two places: a ceiling in the engine’s own configuration, and a X-PS-Scopes header on the request itself. The two intersect, and the intersection is never wider than either one. Naming a scope the ceiling does not grant is therefore not an escalation and not an error the scope is simply absent, and the route that needed it answers 403 SCOPE_DENIED.

On top of that, 2 scopes carry a second gate. Anything that would place, cancel or move value, or that would rewrite what the engine believes about your orders, has to be confirmed deliberately on the request or it is refused. The scope check runs first, so a caller that was never given the scope never reaches the confirmation gate at all.

Every scope, both headers and the discovery route are documented in the next section.

In a demo build

The demo build is read-only. Every request that is not a GET is refused with 403 demo_read_only before any scope is consulted, so nothing can be created, computed by POST, backtested or written to the journal from a demo. The MCP and CLI surfaces are not compiled into it at all; REST and the WebSocket stream are.

Scopes and confirmation

A scope names one capability. The engine grants every scope unless something takes it away, and nothing a caller sends can add one back.

ScopeWhat it covers
market.readRaw market data: the pair catalogue, OHLCV bars, executed prints, the order book, recorded book history and the footprint. Also the socket’s chart operation
context.readThe compiled context and everything addressable inside it one timeframe, one structural read, one live orderflow read
indicators.readThe indicator catalogue, the custom-indicator guide and library, validation, preview, and computing indicators over a pair
indicators.writeCreating, updating and deleting custom indicators
structure.readDerived market structure and the book-derived orderflow metrics
events.readMarket events: the live feed, the type catalogue and the detector’s own status
strategy.readReading strategies, versions, diffs, explanations, the schema and the worked examples and validating a draft that was never stored
strategy.writeCreating, patching, refining and deleting strategies. Every write makes a new version
backtest.runStarting a backtest, cancelling one, and removing a finished job together with its report
backtest.readListing and reading jobs and their reports, comparing two of them, and scoring a set of trades you already hold
journal.readReading journal entries, statistics, the calendar, the daily rollup and the journal’s own status
journal.writeCreating, patching, deleting and importing journal entries, and appending attachments
orders.readReading working orders and completed order history. Also the socket’s orders channel
orders.writeClearing local order history and re-polling the venue. Both also require confirmation
favorites.writeStarting and stopping tracking of a pair
subscriptions.writeCreating, changing, deleting and test-firing webhook subscriptions
exchange.readVenue operations that only ask questions: balances, positions, order state, venue-side market data
exchange.tradeVenue operations that place or cancel an order, close a position, move funds, or change leverage, margin or position mode. Also requires confirmation
config.readHealth, status and the effective configuration. Also the socket’s status channel
A scope this caller does not hold is a 403 on REST, an error frame on the socket, and over MCP a tool that was never registered at all so an agent cannot be tempted by a capability it does not have.
Two scopes, one extra stepX-PS-Confirmed

Moving value has to be said out loud

Holding exchange.trade or orders.write is not by itself permission to use them. A request under either scope must also carry X-PS-Confirmed: 1, and one that does not is refused with 428 CONFIRMATION_REQUIRED. The refusal happens before the venue is contacted, so nothing is half-done.

The gate can be switched off wholesale in the engine’s configuration the right setting for a desk you drive yourself, and the wrong one for an agent you are still learning to trust. It cannot be switched off per request, and it is evaluated after the scope check, so a caller without the scope is told about the scope and not about the confirmation.

What the gate covers

  • DELETE /api/{mt}/orders/history Clearing the local order history. It touches no venue, but it destroys the record a reconciliation would have used
  • POST /api/{mt}/orders/refresh Re-polling the venue for order state. It reaches the exchange with your credentials and rewrites what the engine believes
  • POST /api/{mt}/exchange/{method} Every venue operation marked as moving value above the ones that place or cancel an order, close a position, transfer funds, or change leverage, margin or position mode
narrowed
# a narrowed caller, confirming the one thing it is about to docurl -s -X POST http://127.0.0.1:8420/api/futures/exchange/createMarketContractOrder \  -H 'Content-Type: application/json' \  -H 'X-PS-Scopes: market.read,context.read,orders.read,exchange.read,exchange.trade' \  -H 'X-PS-Confirmed: 1' \  -d '{"platform":"binance","symbol":"BTCUSDT","side":"BUY",       "type":"MARKET","quantity":0.01}' # the same call with the header but no confirmation#   428  { "ok": false, "error": { "code": "CONFIRMATION_REQUIRED", … } } # the same call with exchange.trade left out of the header#   403  { "ok": false, "error": { "code": "SCOPE_DENIED", … } }
The same call twice: once confirmed, once not. Scope first, confirmation second nothing reaches the venue until both have passed.

Ask what you still have

One route sits outside the scope system entirely. GET /api/agent/capabilities answers however far a caller has narrowed itself, because an agent that has just been handed a smaller grant needs a way to learn what that grant is without discovering it one 403 at a time.

The answer describes the whole vocabulary and this caller’s place in it: which capabilities exist and what each is for, which of them this request holds, which need confirmation and how to send it, which socket channels and operations the same rules apply to, where narrowing can be applied, and which feature modules this build actually carries. Send it with a narrowing header and it answers for the narrowed caller, which makes it the cheapest way to test a header before you rely on it.

capabilities
# what this caller may do right nowcurl -s http://127.0.0.1:8420/api/agent/capabilities # what it would be able to do if it narrowed itself firstcurl -s http://127.0.0.1:8420/api/agent/capabilities \  -H 'X-PS-Scopes: market.read,context.read,indicators.read'
Unscoped by design. It is the only route a narrowing header cannot take away from you.

The envelope

Success and failure share a discriminator. Check ok first; everything else follows from it.

response
{ "ok": true,  "data": { … } }{ "ok": false, "error": { "code": "<code>", "message": "<message>" } }

Responses are application/json; charset=utf-8 and Cache-Control: no-store. Protocol-level failures use the envelope too, so a client never has to parse two shapes.

One route answers outside it. The chart route can return raw PNG bytes — ?raw=1, or an Accept header that ranks image/png above JSON. That selects the shape of a success only: a failure is still an envelope. Curl’s default Accept: */* ties and loses, so a plain curl gets the envelope.

OPTIONS on any path answers the preflight for GET, POST, PATCH, DELETE, OPTIONS. PATCH is on the list because the subscription enable/disable route uses it, and a verb missing from a preflight is a verb a browser will not send.

The same answer advertises every request header the engine accepts — Content-Type and X-Engine-Token for the transport, then X-PS-Scopes and X-PS-Confirmed for the authorization layer. A header a preflight does not advertise is a header a browser will strip before you ever see the failure.

preflight
curl -s -i -X OPTIONS http://127.0.0.1:8420/api/subscriptions \  -H 'Origin: http://localhost:3000' \  -H 'Access-Control-Request-Method: PATCH'

Errors

Switch on error.code, never on message text. The code set is frozen; the prose is free to improve.

The API is its own reference

Errors name the field and enumerate the valid set

Nothing is silently substituted, clamped or ignored. A parameter outside its allowed set is refused, and the refusal carries the offending field name followed by every value that would have worked. A typo in a timeframe, a style, a read key or an operator is therefore one request away from an answer, and you never need a hardcoded copy of an enum to write a correct client.

The same discipline applies to defaults: omit an optional parameter and the engine applies its own configured value. Send a bad one and it tells you. It will not quietly pick something near what you asked for.

HTTPCodeWhen
400validation_errorA parameter is outside its allowed set or range. The message names the field and lists what would have worked
400invalid_jsonThe request body is not valid JSON
400bad_requestMalformed URL or malformed HTTP request
401unauthorizedA shared token is configured and the request did not carry it
403forbidden_urlA webhook URL failed the destination policy: wrong scheme, embedded credentials, or a host the engine is not allowed to call
403origin_forbiddenThe request named an Origin the engine does not answer to. A request that sends no Origin at all curl, a server-side job, the MCP surface never reaches this check
403SCOPE_DENIEDThe route needed a capability this caller does not hold, because the engine’s configured ceiling never granted it or because a narrowing header on this request left it out. Narrowing is deliberate and is not itself an error, so this is the only signal you get ask the capabilities route what you actually hold
404not_foundUnknown route, unknown venue operation, unknown pair, a context route on a pair that is not favorited or has not populated yet, nothing to chart, or no such subscription
404not_favoritedA raw market-data route was asked for a pair the engine is not tracking. Its own code rather than a bare not_found, because the fix is to favorite the pair and not to correct the URL
405method_not_allowedThe route exists, the verb does not. The message lists the verbs it does accept
409duplicateA subscription with the same canonical rule and the same URL already exists
413payload_too_largeThe request body is over the accepted size
428CONFIRMATION_REQUIREDThe capability was held and the deliberate confirmation was not sent. Only the two value-moving scopes can produce this, and they produce it before the venue is contacted repeat the request with the confirmation header
429too_many_requestsRenders or feed reads are already at their concurrency ceiling, or the subscription count is at its ceiling
500request_timeoutThe response deadline was reached
500internal_errorUnexpected failure
500serialization_errorThe response payload could not be serialised
500render_failedThe chart renderer failed unexpectedly
502upstream_errorThe exchange rejected or failed the call
502upstream_timeoutThe exchange call exceeded its deadline
503service_unavailableSubscriptions are turned off, or the delivery dispatcher is not running
504render_timeoutSourcing the chart’s bars exceeded its deadline
504feed_timeoutSourcing a raw market-data read exceeded its deadline. A separate code from render_timeout because they are separate deadlines
A 404 on a route that exists means the resource is not there yet an untracked pair, or a context still warming up and not_favorited is the code that tells you which. A 405 means the path was right and the verb was not.

Codes the feature modules add

The table above is the engine’s own frozen set, written in lower snake case. The five feature modules add their own codes alongside it, and those are written in upper snake case — STRATEGY_NOT_FOUND, BACKTEST_JOB_NOT_FOUND, CUSTOM_INDICATOR_INVALID and so on. The convention is deliberate: the case of a code tells you whether the core dispatcher or a module refused you, before you have read the message.

A module refusal may also carry more than the two envelope fields. A validation failure attaches error.errors[] every problem at once, each with the path in your document that caused it and some refusals attach error.data with the detail a client needs to recover, such as which strategy still references the indicator you tried to delete. Both are additions to the failure envelope, never replacements for it, so a client that only reads the code keeps working.

Global routes

Not market-typed. Engine state, the live configuration, capability discovery and the whole subscription surface. The feature modules add a further 42 global route shapes on top of these, each documented in its own section further down.

MethodPathWhat it does
GET/api/healthLiveness and uptime. Touches nothing but the clock, so it answers even while the engine is busy
GET/api/statusRuntime snapshot: uptime, how many pairs are tracked, how many streams are live, cache readiness, process memory, and the effective configuration with credential values masked
GET/api/configThe effective configuration, credentials masked. Read-only
GET/api/platformsThe eight exchanges and how many spot and futures pairs each contributes to the local catalogue. The platform ids here are the only values every other route accepts
GET/api/agent/capabilitiesWhat this caller may do: the scope vocabulary, which of it this request holds, what needs confirming, and which feature modules the build carries. The one route no narrowing can take away
POST/api/subscriptionsCreate a webhook subscription. Body: rule, url, and optional secret, confirm, cooldownMs, rearm, enabled, label
GET/api/subscriptionsEvery subscription, enabled or not. Always an array
GET/api/subscriptions/{id}One subscription record
PATCH/api/subscriptions/{id}Enable or disable. Body: enabled the only mutable field; any other key is a 400
DELETE/api/subscriptions/{id}Delete permanently. Events it already wrote to the log survive
POST/api/subscriptions/{id}/testSend one signed test delivery now, without waiting for the rule to fire. No body
GET/api/eventsReplay the durable event log. Query: since (inclusive cursor), limit

Market-typed routes

Replace {mt} with spot or futures. Both are served identically the same paths, the same parameters, the same envelope.

MethodPathWhat it does
GET/api/{mt}/pairsSearch the local pair catalogue. Query: platform, query (symbol substring), limit (0 for unlimited)
GET/api/{mt}/favoritesThe pairs this engine is actively tracking
POST/api/{mt}/favoritesStart tracking a pair. Body: platform, symbol, and optional timeframes, tier
DELETE/api/{mt}/favorites/{platform}/{symbol}Stop tracking a pair and release its streams
GET/api/{mt}/context/{platform}/{symbol}The compiled, price-stamped context for one pair every tracked timeframe plus the live orderflow reads, in one answer. Query: include = kline | orderflow to take one half
GET/api/{mt}/context/{platform}/{symbol}/kline/{timeframe}The structural analysis for one timeframe: all sixteen reads
GET/api/{mt}/context/{platform}/{symbol}/kline/{timeframe}/{section}One structural read, unwrapped. An unknown section is a 400 that lists the valid ones
GET/api/{mt}/context/{platform}/{symbol}/orderflow/{section}One live orderflow read, unwrapped
GET/api/{mt}/chart/{platform}/{symbol}/{timeframe}A PNG chart. Query: width, height, style, theme, indicators, bars, showVolume, raw
GET/api/{mt}/klines/{platform}/{symbol}/{timeframe}Raw OHLCV bars, oldest first. Query: limit, before (scroll-back cursor)
GET/api/{mt}/trades/{platform}/{symbol}Recent executed prints, oldest first. Query: limit
GET/api/{mt}/depth/{platform}/{symbol}An order-book snapshot, bids descending and asks ascending. Query: levels
GET/api/{mt}/depth/{platform}/{symbol}/historyRecorded book snapshots, oldest first. Query: limit, before
GET/api/{mt}/footprint/{platform}/{symbol}The one-minute bid × ask footprint, oldest first. Query: limit, before
GET/api/{mt}/ordersWorking orders the engine is tracking. Query: platform, symbol, limit
GET/api/{mt}/orders/historyCompleted orders, newest first. Query: before, limit, query
DELETE/api/{mt}/orders/historyClear the local order history. Does not touch the exchange, but it destroys the record a reconciliation would have used, so it needs the confirmation header
POST/api/{mt}/orders/refreshRe-poll the venue for the current state of tracked orders, and answer with the refreshed positions alongside them. Body: platform (required), optional symbol, and the same optional per-request credentials the passthrough takes. Reaches the exchange, so it needs the confirmation header
POST/api/{mt}/exchange/{method}The venue passthrough one of the 23 operations below. Body is the operation’s parameter object and must include platform. Read operations need only the read scope; the ones that move value need the trade scope and the confirmation header
12 core global shapes and 19 core market-typed shapes are listed in these two tables. The 5 feature modules add 42 more global shapes and 6 more market-typed ones, in the sections below. All together: 54 global + 25 × 2 market-typed = 104 path+verb routes.

Charts

Any catalogued pair, nine timeframes, four styles, two themes. Drawing is local: no headless browser, no chart service, no network round trip at render time.

ParameterAcceptsDefault
{timeframe}1m 3m 5m 15m 30m 1h 4h 1d 1wPath segment, not a query parameter
width320 – 2560Configured default
height240 – 1440Configured default
stylecandle · line · area · heikinashiConfigured default
themedark · lightConfigured default
indicatorsCSV of EMA, RSI, MACD, VWAP at most 4, dedupedNone
bars20 – 1000Configured default
showVolumetrue · false · 1 · 0true
rawtrue · false · 1 · 0Unset the Accept header decides
Omitting a parameter takes the engine's configured default. Supplying a bad one is a 400 naming the field and its valid set. Nothing is clamped.

The chart route and the OHLCV route are the two that work on a pair you have not favorited: both can backfill from the exchange. A tracked pair is drawn straight from memory instead.

Venue operations

POST /api/{mt}/exchange/{method} is a typed passthrough to the venue. One vocabulary across all eight exchanges: the operation names and parameter names below are the same everywhere, and the engine normalises what comes back.

Real moneynever retried

Mutating operations are never replayed

Operations marked ⚠ act on a live account whenever credentials are present placing or cancelling orders, moving funds, or changing leverage, margin and position mode for every order that comes after. A read that times out may be retried; a mutation that times out never is an order the venue accepted a second before the deadline is resting at the exchange, and a retry of it is a second position nobody asked for.

Those same operations are the ones the authorization layer guards most closely. They need exchange.trade rather than the read scope, and each request must also carry X-PS-Confirmed: 1 or it is refused with 428 CONFIRMATION_REQUIRED before the venue is contacted. The unmarked operations need only the read scope and no confirmation.

Credentials may be supplied per request as apiKey, apiSecret and apiPhrase in the body, which overrides whatever the engine already holds. Values are never echoed back, never logged, and stripped out of any error the venue returns. platform is always required.

Spot 7 operations

OperationParametersMoves value
getUserWalletBalanceInfosymbol
getTradMarketInformationsymbol, timeframe, limit, startTime
getTradeOrderInformationorderId, symbol
extractTradeInfotradinfo
createMarketTradeOrderBxsymbol, side, type, amount, price, timeforce, stopPrice, clientOrderIdYes moves value
cancelMarketTradeOrderBxorderId, symbolYes moves value
transferUsertradeProfitsamount, symbolYes moves value

Futures 16 operations

OperationParametersMoves value
getUserWalletBalanceInfosymbol
getMarketContractPositionsymbol
getContractOrderInformationorderId, symbol
getMarketContractTradeStatesymbol, OrderIds { stopId, takeProfitId }
getContractMarketInformationsymbol, timeframe, limit, startTime
getContractMarketOpenInterestsymbol, timeframe
extractTradeInfotradinfo
createMarketContractOrdersymbol, side, type, quantity, price, timeforce, stopPrice, reduceOnly, positionSide, closePosition, workingType, clientOrderIdYes moves value
createBracketContractOrdersymbol, side, type, quantity, stopLoss, takeProfit, price, clientOrderIdYes moves value
closeMarketContractPositionsymbolYes moves value
cancelMarketContractOrderorderId, symbolYes moves value
cancelAllMarketContractOrdersymbolYes moves value
setMarketContractLeveragesymbol, leverageYes moves value
setMarketContractMarginTypesymbol, marginTypeYes moves value
setMarketContractPositionModepositionModeYes moves value
transferUsertradeProfitsamount, symbolYes moves value
An unknown operation name is a 404 that lists the allowlist for that market type.

Three granularities

The same analysis, addressable at three widths. Ask for as much or as little as your consumer can use.

  • The compiled context — /context/{platform}/{symbol}. One price-stamped snapshot of the pair: every tracked timeframe plus the live orderflow reads. Narrow it with ?include=kline or ?include=orderflow; an unknown value is a 400 listing the two.
  • One timeframe — /context/{platform}/{symbol}/kline/{timeframe}. All sixteen structural reads for that horizon.
  • One read — append a {section}, or use /context/{platform}/{symbol}/orderflow/{section}. The value comes back unwrapped, which is what you want when a dashboard tile needs exactly one number.

Context routes serve favorited pairs only, and a pair that has just been favorited answers thinly for the first seconds while it warms up. A 404 on a context route usually means one of those two things rather than a wrong URL.

1m3m5m15m30m1h4h1d1w

Read keys

sectionpath

Read keys are enumerated by the engine, live

A pair carries sixteen structural reads per tracked timeframe and eight live orderflow reads. Each is addressable on its own, and every parameter spelled section takes one of those keys.

This page does not print the key list, because the engine already publishes it and a copy here would be the stale one. To see the keys a pair exposes right now, read one compiled context, or send a deliberate typo and read the 400. Both answers come from the running engine, so they are correct for the version you actually have.

Raw market data

The numbers the analysis is computed from, with nothing on top. Query parameters go to the engine untouched, so a bad limit is refused identically here, over MCP and on the CLI.

RouteQueryOrdering
/klines/{platform}/{symbol}/{timeframe}limit, beforeOldest first
/trades/{platform}/{symbol}limitOldest first, newest last
/depth/{platform}/{symbol}levelsBids descending, asks ascending
/depth/{platform}/{symbol}/historylimit, beforeOldest first
/footprint/{platform}/{symbol}limit, beforeOldest first
before is an epoch-millisecond scroll-back cursor: it returns records strictly older than that time, so you can walk backwards a page at a time. An empty result means the beginning of available history, not an error.

/klines works on any catalogued pair. The other four serve favorited pairs only there is no recorded book or footprint for a pair nobody asked the engine to watch.

Orders

Every order placed through the passthrough is recorded accepted and rejected alike, because a consumer that only ever sees accepted orders cannot tell a quiet session from a broken key.

MethodRouteWhat it does
GET/api/{mt}/ordersWorking orders. Query: platform, symbol, limit
GET/api/{mt}/orders/historyCompleted orders, newest first. Query: before, limit, query
DELETE/api/{mt}/orders/historyClear the local history. The exchange is not touched. Write scope, and the confirmation header
POST/api/{mt}/orders/refreshRe-poll the venue for current state and return the refreshed positions with it. Body: platform (required), symbol?, apiKey?, apiSecret?, apiPhrase?. Write scope, and the confirmation header

Order state is canonicalised across the eight venues, so one status vocabulary works everywhere and you do not write a per-exchange adapter. Credentials are never part of a record.

Three of the four routes touch no network at all they read records this engine already holds, so a status is only as fresh as the last thing it heard. Only /orders/refresh reaches a venue, and it is the one that resolves a stale status. A record exists because the order went through this engine: orders placed directly at the exchange are not visible here.

The two reads need orders.read. The two writes need orders.write and the confirmation header on top of it. Neither of them places an order, and both were still put behind the gate on purpose: one destroys the record you would reconcile against, and the other spends your credentials and rewrites what the engine believes.

Indicators and structure

The engine does not only serve numbers, it computes on them. Indicators, derived market structure and book-derived orderflow metrics are all read over the same envelope as everything above.

MethodPathWhat it does
GET/api/indicatorsThe whole catalogue built-ins and anything you have written yourself. Query: query (name substring), category, ids (comma-separated)
GET/api/indicators/{id}One indicator: what it computes, the parameters it takes and the outputs it produces. An unknown id is a 404
POST/api/{mt}/indicators/computeCompute up to forty entries over one pair, on one shared time axis. Body: symbol, platform?, timeframe, limit?, and indicators[] whose entries carry id, key?, timeframe?, params?
GET/api/{mt}/structureDerived market structure for one timeframe. Query: symbol, platform?, timeframe, limit?, left?, right?, lookback?, atrMult?, atrPeriod?, tolerancePct?
GET/api/{mt}/orderflow-metricsOrderflow metrics computed from the live book. Query: symbol, platform?, levels?, obiDepth?
Reading the catalogue, computing over a pair and previewing a draft all need only the read capability. Creating an indicator of your own is a separate one, and that is the next section.

The catalogue ships with 33 built-in indicators across 7 categories. Anything you write joins the same catalogue under the custom category and is thereafter indistinguishable from a built-in: it lists, it computes by id, and a strategy can refer to it.

7 categories

custommomentumsessionstructuretrendvolatilityvolume

Computing a set

One request computes up to forty entries over a single pair and returns them on one shared axis, so a consumer never lines two series up by hand. Each entry names an id, optional key to control what the output is called back, optional params to override the indicator’s own defaults, and optionally a timeframe of its own.

compute
# two indicators, two timeframes, one shared axiscurl -s -X POST http://127.0.0.1:8420/api/spot/indicators/compute \  -H 'Content-Type: application/json' \  -H 'X-PS-Scopes: indicators.read' \  -d '{"symbol":"BTCUSDT","platform":"binance","timeframe":"5m","limit":500,       "indicators":[         {"id":"rsi","key":"rsi14","params":{"length":14}},         {"id":"ema","key":"trend_1h","timeframe":"1h","params":{"length":200}}       ]}' # derived structure, and the book metrics, for the same paircurl -s 'http://127.0.0.1:8420/api/spot/structure?symbol=BTCUSDT&platform=binance\&timeframe=15m&left=3&right=3&atrMult=1.5&lookback=400' curl -s 'http://127.0.0.1:8420/api/spot/orderflow-metrics?symbol=BTCUSDT\&platform=binance&levels=200&obiDepth=25'
One momentum reading on the request’s own timeframe, one trend reading pulled down from a higher one. Both come back on the same axis.

Multi-timeframe alignment

That per-entry timeframe may be HIGHER than the request’s own, and this is the part worth understanding before you rely on it. The value at each bar is the last higher-timeframe bar that had already closed by that point never a value from the future and the answer records the bar at which the alignment became valid, so you can drop the warm-up window yourself instead of guessing at it. A LOWER timeframe is refused outright: there is no honest way to spread one bar across many.

Derived structure

One GET returns the structural analysis of a timeframe: every structural read for that horizon, in one answer. The sensitivity is yours to set the pivot arms either side of a swing, the lookback, the ATR multiple and period, and how far apart two prices may be and still count as equal.

Orderflow metrics

The same idea computed from the live book rather than from bars, over the depth you choose. A metric that cannot honestly be computed at this moment comes back declared unavailable with the reason attached. It is never a silent zero, because a zero is a number a strategy will happily trade on.

Custom indicators

You can add an indicator the engine did not ship with. It is written as a small declarative formula language, validated before it is stored, and kept as a first-class member of the catalogue.

MethodPathWhat it does
GET/api/indicators/custom/guideThe grammar this engine will actually parse: bar fields, series functions, the callable built-ins, worked examples and every limit. Read it before you write anything
GET/api/indicators/customEvery custom indicator this engine has stored
GET/api/indicators/custom/{id}One stored definition
POST/api/indicators/custom/validateCheck a definition without storing it. Answers with every problem at once, each carrying the path in your definition that caused it and, for a formula, the character position
POST/api/{mt}/indicators/custom/previewRun an unsaved definition over a favorited pair and see the numbers, with a summary per output. Body: symbol, platform?, timeframe, bars?, definition, params?
POST/api/indicators/customStore it. An invalid definition is a 422 that lists every error rather than the first one
PUT/api/indicators/custom/{id}Merge changes into a stored definition. Params and outputs are replaced whole rather than merged element by element, and the version counter moves
DELETE/api/indicators/custom/{id}Remove it. Refused while a saved strategy still refers to it
These and the section above are the whole analytics module: 13 route shapes. Reading, validating and previewing need the read capability; storing, updating and deleting need the write one.

What a definition is

A definition carries an id, a name, at most eight params and between one and eight outputs. Leave the id out and it is derived from the name; every id is prefixed, so a custom indicator can never collide with a built-in one.

A param declares its type integer, number, enum or boolean a default, and the bounds or options it will accept. An output declares its formula plus how it should be drawn: which kind of mark, which pane it belongs in, what it is anchored to, and which side it argues for.

Outputs evaluate in the order they are declared and may read the outputs declared before them. Referring forward is a cycle, and a cycle is refused rather than resolved.

squeeze-pressure.json
{  "id": "cx_squeeze_pressure",  "name": "Squeeze Pressure",  "description": "Band width against its own average, with a compression flag.",  "version": 1,  "tags": ["volatility", "squeeze"],  "aiHint": "width falls as the bands compress; squeezed turns 1 underneath it.",  "params": [    { "key": "length", "type": "int",    "default": 20, "min": 5,   "max": 200 },    { "key": "mult",   "type": "number", "default": 2,  "min": 0.5, "max": 5 },    { "key": "strict", "type": "bool",   "default": false }  ],  "outputs": [    {      "key": "width",      "formula": "(bollinger(length, mult).upper - bollinger(length, mult).lower) / nz(sma(close, length), close)",      "kind": "line",      "pane": "sub",      "anchor": "none",      "side": "neutral",      "label": "Band width"    },    {      "key": "squeezed",      "formula": "iff(width < (strict ? 0.015 : 0.025), 1, 0)",      "kind": "histogram",      "pane": "sub",      "anchor": "none",      "side": "bear",      "label": "Squeeze"    }  ]}
Two outputs, where the second reads the first. Params are referenced by key inside a formula, and the built-in call carries its arguments the way the guide spells them.

What a formula may do

A formula is never evaluated as JavaScript, so a definition is data you stored and not code you are running. Three invariants follow from that, and all three are worth designing around.

  • Every bar answers. A value that cannot exist yet is null on the wire rather than absent, so a series always has exactly one entry per bar and never silently shortens.
  • Nothing loops. There is no iteration and no recursion, and every window is a constant, so the number of bars an indicator needs before it can answer is known exactly instead of being discovered at run time.
  • Null propagates. Any arithmetic that touches a null is null. Handle it deliberately with the null-coalescing and conditional helpers rather than hoping it will not come up.

Bar fields, series functions and callable built-ins are three different things, and the guide route enumerates all three for the version you are running. One trap is worth stating here, because it catches almost everyone: sma, ema, wma and atr are series functions and take the series first. Write ema(close, 20) and not ema(20). A custom indicator also cannot call another custom indicator.

Limits

LimitValueWhat it bounds
maxFormulaChars2000The longest a single formula may be
maxParams8Parameters a definition may declare
maxOutputs8Outputs a definition may declare
maxIndicatorCalls24Calls to built-in indicators across the whole definition
maxBars5000Bars one preview or compute may be asked for
A definition outside any of these is refused, and the refusal names the limit it broke. One that is valid but too expensive to compute is refused too, under its own error code, instead of timing out anonymously.

Guide, validate, preview, create

Four steps in that order, and each exists to make the next one cheap. Read the guide for the grammar this engine will actually parse. Validate to get every problem at once, each with the path that caused it. Preview over a favorited pair to see real numbers before anything is stored. Then create.

An indicator that a saved strategy still refers to cannot be deleted the refusal is CUSTOM_INDICATOR_IN_USE, and it names what is holding the reference. Change the strategy first. Stored definitions survive a restart; there is no version history behind them, because the version is a counter and not a log.

lifecycle
# 1  the grammar this engine will actually parsecurl -s http://127.0.0.1:8420/api/indicators/custom/guide # 2  every problem at once, before anything is storedcurl -s -X POST http://127.0.0.1:8420/api/indicators/custom/validate \  -H 'Content-Type: application/json' \  -d @squeeze-pressure.json # 3  real numbers on a favorited pair, still storing nothingcurl -s -X POST http://127.0.0.1:8420/api/spot/indicators/custom/preview \  -H 'Content-Type: application/json' \  -d '{"platform":"binance","symbol":"BTCUSDT","timeframe":"15m","bars":300,       "definition": { … }, "params":{"length":34}}' # 4  store it from here on it is just another indicatorcurl -s -X POST http://127.0.0.1:8420/api/indicators/custom \  -H 'Content-Type: application/json' \  -H 'X-PS-Scopes: indicators.read,indicators.write' \  -d @squeeze-pressure.json
Validate refuses, preview computes without storing, create stores. Only the last step needs the write capability.

Strategies

A strategy here is a document, not a script: entries, filters, exits, sizing and risk declared as data the engine can validate, explain, diff and simulate. Nothing you store is executed as code.

MethodPathWhat it does
GET/api/strategies/schemaThe definition schema, in sections. Query: section = overview | inputs | conditions | exit | risk | meta | all, defaulting to the overview
GET/api/strategies/examplesWorked definitions, plus the analytics outputs they assume are available
POST/api/strategies/validateCheck a draft that has never been stored. Answers with every problem, the indicators it resolved, how many bars are needed before a first signal, and the document it would have stored
POST/api/strategies/explainExplain an unsaved draft in prose, so an author can confirm the engine read it the way it was meant
GET/api/strategiesEvery stored strategy, with its current version and the versions behind it
POST/api/strategiesStore one. An invalid definition is a 422 carrying every error, not the first
GET/api/strategies/{id}One strategy: the definition, which version is current, and the version list
PATCH/api/strategies/{id}Change one. Body is either a list of operations or a partial definition; an empty patch is a 400
DELETE/api/strategies/{id}Remove the strategy and every version of it
GET/api/strategies/{id}/versionsThe version history
GET/api/strategies/{id}/versions/{n}One version, exactly as it was stored
POST/api/strategies/{id}/refineApply a list of operations and record why. Body: ops (required), changeSummary?, origin?
GET/api/strategies/{id}/diffWhat changed between two versions. Query: from, to
GET/api/strategies/{id}/explainExplain a stored strategy in prose. Query: version
Reading is one capability and writing is another, so a research agent can be handed the entire strategy surface without being able to change a single stored strategy.

What a definition holds

A definition names the market it was written for platform, market type, symbol and timeframe then declares its inputs, the conditions that open a position long or short, the filters that may veto one, the exit, and a risk block: how size is chosen, how many positions may be open at once, the daily loss that stops trading, the least reward-to-risk worth taking, and the conditions to sit the session out entirely.

None of it is free text the engine has to interpret. Validation resolves every indicator the document refers to, reports how many bars it needs before it can produce a first signal, and hands back the normalised document it would actually have stored so what you review is what would run.

strategy.json
{  "name": "London sweep reclaim",  "description": "Take the Asian low, reclaim it inside London, target the opposing side.",  "origin": "manual",  "market": {    "platform": "binance",    "marketType": "futures",    "symbol": "BTCUSDT",    "timeframe": "15m"  },  "inputs":  [ … ],  "entry":   { "long": { … }, "short": { … } },  "filters": [ … ],  "exit":    { … },  "risk": {    "sizing": { … },    "maxConcurrent": 1,    "maxDailyLoss": 2,    "minRR": 1.8,    "skipIf": [ … ]  },  "meta": { "tags": ["liquidity", "session"], "notes": "…" }}
A complete definition, elided only where the shape repeats. Inputs are referenced by key from the conditions below them.

Versions, refinement and diff

Every write makes a new version, and the old one stays readable. A refinement is expressed as a list of operations — set, unset, push, remove against a dotted path rather than as a whole replacement document, so the record of what changed is the change itself and not a reconstruction made afterwards. The new version records the version it came from, the fields the edit touched, and a summary of it.

Two versions can be diffed, and any version can be explained in prose. A draft that was never stored can be validated and explained too, which is what makes an author-then-check loop cheap: nothing is written until the document is worth keeping.

The schema, in sections

The whole schema is large enough to be unhelpful as a single answer, so it is served in sections and defaults to the overview. Ask for the section you are currently writing.

overviewinputsconditionsexitriskmetaall
refine
# ask for only the part of the schema you are about to writecurl -s 'http://127.0.0.1:8420/api/strategies/schema?section=risk' # refine a stored strategy, and say whycurl -s -X POST 'http://127.0.0.1:8420/api/strategies/{id}/refine' \  -H 'Content-Type: application/json' \  -H 'X-PS-Scopes: strategy.read,strategy.write' \  -d '{"changeSummary":"Tighter stop, one position at a time",       "origin":"assistant",       "ops":[         {"op":"set",   "path":"risk.maxConcurrent", "value":1},         {"op":"set",   "path":"exit.stop.mult",     "value":1.2},         {"op":"push",  "path":"meta.tags",          "value":"tightened"},         {"op":"unset", "path":"risk.skipIf"}       ]}' # what that actually changedcurl -s 'http://127.0.0.1:8420/api/strategies/{id}/diff?from=3&to=4'
Four operations against dotted paths, with the reason recorded beside them. The result is a new version, not an overwrite.

Backtests

A run is a job. You queue it, you watch it, you read its report. Nothing about a backtest happens inside the request that started it.

MethodPathWhat it does
POST/api/{mt}/backtestsQueue a run over a stored strategy or an inline definition. Body: strategyId?, version?, definition?, market?, symbol?, platform?, timeframe?, range?, fees?, slippageBps?, initialEquity?, quoteCurrency?, closeAtEnd?. Answers immediately with a job id
GET/api/backtestsEvery job. Query: strategyId, status, limit
POST/api/backtests/compareCompare exactly two finished jobs. Body: jobIds
GET/api/backtests/{id}The job and its progress, plus its report once it is done
DELETE/api/backtests/{id}Cancel a running job. Idempotent a job that has already finished simply reports that nothing was cancelled
DELETE/api/backtests/{id}/reportRemove the job and its report permanently. A job that is still live is a 409; cancel it first
POST/api/backtests/score-tradesScore a set of trades you already hold, with no run at all. Body: trades (required), equityCurve?, initialEquity?, quoteCurrency?
Starting, cancelling and removing all need the run capability; listing, reading, comparing and scoring need the read one. A reviewer can therefore read every result without being able to start a single job.

Create, watch, read

A create returns a job id and a queued status, and nothing else. Poll the job, or subscribe to the backtest channel on the socket and be told. The report arrives attached to the job once its status is done.

The two DELETEs are different verbs wearing the same word, and the distinction matters.

  • DELETE /api/backtests/{id} — Deleting the job cancels it. It is idempotent, so cancelling something already finished is not an error it reports that there was nothing to cancel. The job stays, and so does its report.
  • DELETE /api/backtests/{id}/report — Deleting the report removes both. The job and its report go permanently. A job that is still live cannot be removed this way; cancel it first, then remove it.

Exactly one code means the job is gone — 404 BACKTEST_JOB_NOT_FOUND. Everything else is a job you can still ask about, which is what lets a client distinguish "removed" from "not ready", "cancelled" and "failed" without guessing. A job whose report is no longer available still answers, and says so, rather than pretending it never had one.

backtest
# queue a run answers immediately with a job idcurl -s -X POST http://127.0.0.1:8420/api/futures/backtests \  -H 'Content-Type: application/json' \  -H 'X-PS-Scopes: backtest.run,backtest.read' \  -d '{"strategyId":"{id}","version":4,       "range":{"bars":5000},       "fees":{"takerPct":0.04},       "slippageBps":1,       "initialEquity":10000,       "quoteCurrency":"USDT",       "closeAtEnd":false}' # the job, its progress, and its report once status is donecurl -s 'http://127.0.0.1:8420/api/backtests/{jobId}' # cancel a run in flight it stays in the list, cancelledcurl -s -X DELETE 'http://127.0.0.1:8420/api/backtests/{jobId}' # remove the job AND its report, once it is no longer livecurl -s -X DELETE 'http://127.0.0.1:8420/api/backtests/{jobId}/report'
Queue, poll, and cancel. The job id is the only thing you need to carry between the three.

Statuses

queuedrunningdonefailedcancelled

Three of the five are terminal: done, failed and cancelled. A job in any of them will never move again.

Progress

PhaseWhat is happening
loading_historyBars are sourced for the market and range you asked for
computing_indicatorsEvery indicator the definition refers to is computed once, across the whole range
simulatingBars are walked forward and positions are opened, managed and closed
scoringTrades become metrics, curves and the breakdowns the report is made of
Progress reports a percentage and the phase it is in. It never moves backwards, so a progress bar driven by it never moves backwards either.

Defaults worth knowing

ParameterDefaultNote
fees.takerPct0.04A PERCENT, not basis points. The default means four hundredths of one percent per side, and passing 4 here would charge you four percent
slippageBps1Basis points, applied against you on both entry and exit
initialEquity10000Denominated in the quote currency
quoteCurrencyUSDTWhat equity, PnL and notional are reported in
range.bars1000Bars used when no explicit range is given. The ceiling is 5000
closeAtEndfalsePositions still open at the end are reported as open rather than marked out at the last bar, so an open trade never flatters the result
Everything else about a run is yours to state. Omit a field and the engine applies the value above; send a bad one and it names the field, exactly as everywhere else on this page.

Trade journal

A record of what you actually did, next to what the engine actually saw. Entries can be captured from your own order flow or written by hand, and both kinds are scored the same way.

MethodPathWhat it does
GET/api/journal/entriesList entries, newest page first. Query: from, to, symbol, platform, marketType, status, origin, direction, strategyId, setup, tag, offset, limit (at most 500)
POST/api/journal/entriesWrite one by hand. Body is the entry core
GET/api/journal/entries/{id}One entry
PATCH/api/journal/entries/{id}Change one. Only the fields that are honestly revisable can be patched, and attachments sent here REPLACE the list
DELETE/api/journal/entries/{id}Delete one
POST/api/journal/entries/{id}/attachmentsAppend attachments to an entry, leaving the existing ones alone. Body: attachments, or a single attachment
GET/api/journal/statsAggregate performance over the same filters the list takes, plus initialEquity
GET/api/journal/calendarA dense month grid. Query: month (required, YYYY-MM), timezoneOffsetMinutes, plus the list filters
GET/api/journal/dailyThe same numbers rolled up per day rather than per month. Query: timezoneOffsetMinutes, plus the list filters
POST/api/journal/importImport many entries at once. All of them are accepted or none are
GET/api/journal/statusWhether the journal is enabled and running, and whether it is capturing from the engine’s own order stream
Reading is one capability and writing is another. A reporting agent can read every entry, every statistic and the whole calendar without being able to change a single record.

What an entry is

An entry is one trade: where it happened, which way you were positioned, when you got in and out and at what price, what it cost, what you had planned, how you file it, and what you made of it afterwards.

Two things about it are fixed rather than editable, and both are deliberate. Identity facts symbol, direction, entry time, entry price and size cannot be patched: an entry whose entry price can be edited afterwards is not a record of anything. If one of them is wrong, delete the entry and write it again. And attachments carry a reference only, never bytes; the journal stores where a chart or an image is, not the file itself.

entry.json
{  "origin": "manual",  "platform": "binance",  "marketType": "futures",  "symbol": "BTCUSDT",  "direction": "long",  "status": "closed",  "entryTime": "2026-09-08T07:14:00.000Z",  "entryPrice": 61250.5,  "exitTime": "2026-09-08T09:02:00.000Z",  "exitPrice": 62110,  "qty": 0.35,  "fees": 8.42,  "quoteCurrency": "USDT",  "plannedStop": 60890,  "plannedTarget": 62400,  "strategyId": "{id}",  "strategyVersion": 4,  "setup": "London sweep reclaim",  "tags": ["liquidity", "session"],  "mood": "calm",  "notes": "Waited for the reclaim candle to close rather than front-running it.",  "quality": { "followedPlan": true, "entryGrade": "B", "exitGrade": "A", "mistakes": [] },  "attachments": [ { "kind": "chartshot", "ref": "…" } ]}
One closed long, written by hand. Everything derived PnL, percentage, R multiple is computed from these fields rather than sent with them.

The closed sets

FieldValues
originmanual · captured
statusopen · closed
directionlong · short
moodcalm · anxious · confident · fomo · revenge · bored
entryGrade · exitGradeA · B · C · D
attachments[].kindchartshot · image · link
Anything outside one of these sets is a 400 that names the field and lists the set, exactly as everywhere else. Tags and setup names are free text on purpose: those are your vocabulary, not the engine’s.

Captured and manual

A captured entry is derived automatically from the engine’s own order-update stream, idempotently, so the same fill cannot produce two entries however many times it is seen. A manual entry is for a trade placed somewhere this engine never saw. The two are distinguished by origin so that you can tell them apart later and so that you do not quietly duplicate a captured trade by writing it out again by hand.

How the numbers are derived

PnL is computed rather than accepted: for a long, exit minus entry times size, less fees; for a short, the same with the sides swapped. The percentage is against the entry notional. R multiple needs a planned stop on the correct side of the entry without one there is no R, and the engine reports its absence rather than inventing a denominator.

Statistics carry a behaviour block alongside the performance one, so a review covers how you traded and not only what it returned. The calendar files a trade on the day it CLOSED, in UTC unless you send an offset with the request.

journal
# one filtered pagecurl -s 'http://127.0.0.1:8420/api/journal/entries?symbol=BTCUSDT&status=closed\&origin=captured&from=2026-08-01&limit=100' # the same filters, as totals and as a month gridcurl -s 'http://127.0.0.1:8420/api/journal/stats?symbol=BTCUSDT&status=closed\&initialEquity=10000' curl -s 'http://127.0.0.1:8420/api/journal/calendar?month=2026-08\&timezoneOffsetMinutes=60&symbol=BTCUSDT' # append a chart without disturbing what is already attachedcurl -s -X POST 'http://127.0.0.1:8420/api/journal/entries/{id}/attachments' \  -H 'Content-Type: application/json' \  -H 'X-PS-Scopes: journal.read,journal.write' \  -d '{"attachments":[{"kind":"chartshot","ref":"…"}]}'
The same filters serve the list, the statistics, the calendar and the daily rollup, so a filtered view and its totals can never disagree.

Importing

An import is all-or-nothing. One malformed entry in a file of four hundred rejects the whole file and tells you which ones were wrong, because a half-imported journal is worse than an unimported one you cannot tell what is missing.

Market events

The engine watches the tape and the book for things worth interrupting you about, scores each one, and files it. Nothing here is backfilled or replayed: an event exists because it happened while the engine was watching.

MethodPathWhat it does
GET/api/{mt}/market-eventsScored events for one market type, newest first. Query: symbol, platform, since, until, limit (1–1000), types (comma-separated), side = buy | sell | neutral, urgency = low | medium | high, minScore (0–100)
GET/api/market-events/typesThe type catalogue and the urgency thresholds, so a client never hardcodes either
GET/api/market-events/statusWhether detection is running, which pairs it is attached to, and what it has seen
All three need only the events capability, and none of them can change anything. The same events arrive live on the market-events channel of the WebSocket surface.

The types

There are 16 types. They fall into three rough families: something large happened in the tape, something changed in the book, or the structure of the market itself moved. Filter by any combination of them, or take the lot and filter on score.

large_printblock_tradeliquidity_sweepunusual_volumebuy_pressuresell_pressurespoofingicebergwall_mountedwall_pulledliquidity_vacuumimbalance_clusterabsorptionboschochdisplacement

Scoring and urgency

Every event carries a score from 0 to 100. Filter on it directly when you want a hard floor, rather than inferring importance from the type alone.

Urgency is a band over that score rather than a separate judgement, and both thresholds are published on the types route so that a client never hardcodes them. The three bands are:

highmediumlow

An event says what happened, where it happened, how strongly it scored and which urgency band that score falls in. Nothing in it is a projection.

Repeats

The same thing happening again at the same price is not a new event. It is re-scored, its repetition count moves, and it is re-emitted as an update to the event you already have. A consumer that keys on the event id therefore gets one row that gets more urgent, rather than forty rows that each look like news.

market-events
# only the things worth interrupting you about, on one paircurl -s 'http://127.0.0.1:8420/api/futures/market-events?symbol=BTCUSDT\&platform=binance&types=absorption,liquidity_sweep,displacement\&urgency=high&minScore=75&limit=50' # the vocabulary and the thresholds, so a client hardcodes neithercurl -s http://127.0.0.1:8420/api/market-events/types # is detection running, and on whatcurl -s http://127.0.0.1:8420/api/market-events/status
Ask for one market type at a time. Types, side, urgency and a score floor compose, so a narrow feed is one request rather than a client-side filter.

Webhook subscriptions

A subscription is one predicate evaluated against live market data plus an HTTP endpoint the engine POSTs to when it fires. Seven routes create, read, pause, delete and test them.

MethodRouteBody / query
POST/api/subscriptionsrule, url, secret?, confirm?, cooldownMs?, rearm?, enabled?, label?
GET/api/subscriptions—
GET/api/subscriptions/{id}—
PATCH/api/subscriptions/{id}enabled
DELETE/api/subscriptions/{id}—
POST/api/subscriptions/{id}/test—
GET/api/eventssince, limit

enabled is the only mutable field. Changing a rule, a URL or a trigger control means delete and create a rule that quietly became a different rule is a rule you cannot reason about. Disabling drops it out of evaluation immediately and preserves every counter; enabling puts it back with a clean trigger state, because a sample taken before an unknown gap would otherwise fabricate a crossing.

/test sends one signed delivery right now and reports how the round trip went. It succeeds even when the delivery fails a refused connection comes back as an unsuccessful result, not as an error so read the result rather than the absence of an error. Use it to prove your endpoint is reachable and that your signature check works before you rely on a rule.

Engine codeHTTPEnvelope code
invalid_request400validation_error
not_found404not_found
limit_reached429too_many_requests
forbidden_url403forbidden_url
duplicate409duplicate
disabled503service_unavailable
REST switches on the code, never on message text.

Rule grammar

Exactly one predicate per rule there is no AND, OR or NOT. The same string is accepted verbatim over MCP.

rule
<platform>:<marketType>:<symbol>:<timeframe>:<path> <operator> <value> binance : spot : BTCUSDT : 30m : <path>  crosses_above  68000   |        |        |      |       |          |          |platform    |     symbol    |     path     operator   threshold       marketType       timeframe

<path> addresses a value inside the pair’s published context. Read one compiled context to see exactly which paths a pair currently exposes, then write the dotted route to the value you care about. Paths are case-sensitive.

The timeframe field is always required, because it is what routes the rule into the evaluation index even for a path whose value is not per-timeframe. Whitespace around a symbol operator is optional; the four word operators must be whitespace-delimited.

OperatorValueSemantics
> < >= <=numberTrue for as long as the level comparison holds
== !=number or stringStrict and type-aware no coercion, so the string "46" never equals the number 46. Strings compare case-sensitively after trim
crosses_abovenumberFires on the transition upward through the threshold, not while above it
crosses_belownumberFires on the transition downward through the threshold
entersstringFires on the transition into that state
exitsstringFires on the transition out of that state
Ten operators. A string threshold is quoted; an unquoted numeric literal is a number.
Before you trust a rule

A stored rule is not necessarily a sampling rule

The pair has to be tracked. Creating a subscription does not start tracking a pair that would change the engine’s workload without being asked. A rule addressed at a pair you have not favorited, or at a timeframe that favorite does not track, is accepted and stored and then stays inert. Favorite the pair first.

Level rules and edge rules behave differently. > is true for as long as the value stays above the threshold, so it fires immediately if the condition is already true when you create it. crosses_above needs a previous sample below and a current sample at or above, so it never fires on the first observation. Read them as “tell me while it is above” versus “tell me when it goes above”.

Confirmation, cooldown and hysteresis are always on. They have defaults and you may tune them per subscription, but there is no way to switch any of them off. One noisy tick cannot alert you, a permanently true condition cannot alert you repeatedly, and a value hovering on a threshold has to travel back through a re-arm band before the rule may fire again.

Delivery and signing

A fired rule POSTs JSON to your endpoint with a delivery id, a monotonic cursor and, when you supplied a secret, a signature.

delivery
POST <your webhook url>Content-Type: application/jsonUser-Agent: PriceSylo/<version>X-PriceSylo-Event: subscription.triggeredX-PriceSylo-Delivery: dlv_<id>X-PriceSylo-Seq: <cursor>X-PriceSylo-Signature: sha256=<hex>   omitted entirely when the subscription has no secret

The body is JSON describing the subscription that fired and the value that fired it. The signature covers the exact bytes on the wire verify before you parse, never after a re-stringify, or a difference in key order or whitespace will fail a signature that was perfectly valid.

Delivery retries with backoff on network errors, 429 and 5xx. A subscription whose endpoint keeps failing terminally is auto-disabled rather than left to hammer a dead host; the record says so, and enabling it again resets the failure state.

verify.js
const crypto = require('crypto');const SECRET = 'shared-with-your-receiver';   // the `secret` you passed to create // `raw` is Buffer.concat of the request chunks NOT JSON.parse'd and re-stringified.// The signature is taken over the exact bytes on the wire.const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(raw).digest('hex');const sig = req.headers['x-pricesylo-signature']; let valid = false;if (typeof sig === 'string') {    const a = Buffer.from(expected), b = Buffer.from(sig);    valid = a.length === b.length && crypto.timingSafeEqual(a, b);}
Constant-time comparison. A length-mismatched signature is rejected without leaking where it diverged.

Event log

Every event the engine has emitted is appended to a log with a monotonic cursor that survives restarts. A consumer that was offline catches up instead of losing alerts.

replay
# hand back the cursor you were last given and receive exactly what you missedcurl -s 'http://127.0.0.1:8420/api/events?since=41&limit=100'

The response carries the events, the cursor to pass on your next call, and the oldest cursor still retained. If the oldest retained cursor is greater than the one you asked for, the log has rolled and you missed events which is a thing you can detect and act on, rather than a silence you have to guess at.

The log is independent of the subscriptions themselves: entries written by a subscription that has since been deleted are still replayed.

Four surfaces, one port

Coverage is deliberately uneven, and flattening it would be the easiest thing on this page to get wrong. Market context is on all four. Chart rendering is on three a terminal client has nowhere to put a PNG. Webhook subscriptions are on two, REST and MCP, by decision: a socket you have to keep open is not what a webhook is for.

http://127.0.0.1:8420/api

REST request/response, one envelope. 104 path+verb routes, including the subscription routes.

You are here

node server/cli.js

CLI a terminal client over REST and /stream. 11 commands, payload on stdout.

Ships with the engine