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.
54 global · 25 × 2 market-typed
7 spot · 16 futures, behind one route
On this page
- Overview
- Security boundary
- Scopes and confirmation
- The envelope
- Errors
- Global routes
- Market-typed routes
- Charts
- Venue operations
- Three granularities
- Read keys
- Raw market data
- Orders
- Indicators and structure
- Custom indicators
- Strategies
- Backtests
- Trade journal
- Market events
- Webhook subscriptions
- Rule grammar
- Delivery and signing
- Event log
Overview
Base URL http://127.0.0.1:8420/api
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": { … } }Security boundary
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.
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
19 capabilities · all granted by default · narrowing only
A scope names one capability. The engine grants every scope unless something takes it away, and nothing a caller sends can add one back.
| Scope | What it covers |
|---|---|
market.read | Raw 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.read | The compiled context and everything addressable inside it one timeframe, one structural read, one live orderflow read |
indicators.read | The indicator catalogue, the custom-indicator guide and library, validation, preview, and computing indicators over a pair |
indicators.write | Creating, updating and deleting custom indicators |
structure.read | Derived market structure and the book-derived orderflow metrics |
events.read | Market events: the live feed, the type catalogue and the detector’s own status |
strategy.read | Reading strategies, versions, diffs, explanations, the schema and the worked examples and validating a draft that was never stored |
strategy.write | Creating, patching, refining and deleting strategies. Every write makes a new version |
backtest.run | Starting a backtest, cancelling one, and removing a finished job together with its report |
backtest.read | Listing and reading jobs and their reports, comparing two of them, and scoring a set of trades you already hold |
journal.read | Reading journal entries, statistics, the calendar, the daily rollup and the journal’s own status |
journal.write | Creating, patching, deleting and importing journal entries, and appending attachments |
orders.read | Reading working orders and completed order history. Also the socket’s orders channel |
orders.write | Clearing local order history and re-polling the venue. Both also require confirmation |
favorites.write | Starting and stopping tracking of a pair |
subscriptions.write | Creating, changing, deleting and test-firing webhook subscriptions |
exchange.read | Venue operations that only ask questions: balances, positions, order state, venue-side market data |
exchange.trade | Venue operations that place or cancel an order, close a position, move funds, or change leverage, margin or position mode. Also requires confirmation |
config.read | Health, status and the effective configuration. Also the socket’s status channel |
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/historyClearing the local order history. It touches no venue, but it destroys the record a reconciliation would have usedPOST /api/{mt}/orders/refreshRe-polling the venue for order state. It reaches the exchange with your credentials and rewrites what the engine believesPOST /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
# 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", … } }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.
# 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'The envelope
Every route, every failure, one shape
Success and failure share a discriminator. Check ok first; everything else follows from it.
{ "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.
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
Named, enumerated, never silently fixed
Switch on error.code, never on message text. The code set is frozen; the prose is free to improve.
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.
| HTTP | Code | When |
|---|---|---|
| 400 | validation_error | A parameter is outside its allowed set or range. The message names the field and lists what would have worked |
| 400 | invalid_json | The request body is not valid JSON |
| 400 | bad_request | Malformed URL or malformed HTTP request |
| 401 | unauthorized | A shared token is configured and the request did not carry it |
| 403 | forbidden_url | A webhook URL failed the destination policy: wrong scheme, embedded credentials, or a host the engine is not allowed to call |
| 403 | origin_forbidden | The 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 |
| 403 | SCOPE_DENIED | The 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 |
| 404 | not_found | Unknown 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 |
| 404 | not_favorited | A 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 |
| 405 | method_not_allowed | The route exists, the verb does not. The message lists the verbs it does accept |
| 409 | duplicate | A subscription with the same canonical rule and the same URL already exists |
| 413 | payload_too_large | The request body is over the accepted size |
| 428 | CONFIRMATION_REQUIRED | The 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 |
| 429 | too_many_requests | Renders or feed reads are already at their concurrency ceiling, or the subscription count is at its ceiling |
| 500 | request_timeout | The response deadline was reached |
| 500 | internal_error | Unexpected failure |
| 500 | serialization_error | The response payload could not be serialised |
| 500 | render_failed | The chart renderer failed unexpectedly |
| 502 | upstream_error | The exchange rejected or failed the call |
| 502 | upstream_timeout | The exchange call exceeded its deadline |
| 503 | service_unavailable | Subscriptions are turned off, or the delivery dispatcher is not running |
| 504 | render_timeout | Sourcing the chart’s bars exceeded its deadline |
| 504 | feed_timeout | Sourcing a raw market-data read exceeded its deadline. A separate code from render_timeout because they are separate deadlines |
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
12 core route shapes
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.
| Method | Path | What it does |
|---|---|---|
| GET | /api/health | Liveness and uptime. Touches nothing but the clock, so it answers even while the engine is busy |
| GET | /api/status | Runtime 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/config | The effective configuration, credentials masked. Read-only |
| GET | /api/platforms | The 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/capabilities | What 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/subscriptions | Create a webhook subscription. Body: rule, url, and optional secret, confirm, cooldownMs, rearm, enabled, label |
| GET | /api/subscriptions | Every 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}/test | Send one signed test delivery now, without waiting for the rule to fire. No body |
| GET | /api/events | Replay the durable event log. Query: since (inclusive cursor), limit |
Market-typed routes
19 core route shapes × spot and futures
Replace {mt} with spot or futures. Both are served identically the same paths, the same parameters, the same envelope.
| Method | Path | What it does |
|---|---|---|
| GET | /api/{mt}/pairs | Search the local pair catalogue. Query: platform, query (symbol substring), limit (0 for unlimited) |
| GET | /api/{mt}/favorites | The pairs this engine is actively tracking |
| POST | /api/{mt}/favorites | Start 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}/history | Recorded 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}/orders | Working orders the engine is tracking. Query: platform, symbol, limit |
| GET | /api/{mt}/orders/history | Completed orders, newest first. Query: before, limit, query |
| DELETE | /api/{mt}/orders/history | Clear 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/refresh | Re-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 |
Charts
PNG on demand, rendered in-process
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.
| Parameter | Accepts | Default |
|---|---|---|
{timeframe} | 1m 3m 5m 15m 30m 1h 4h 1d 1w | Path segment, not a query parameter |
width | 320 – 2560 | Configured default |
height | 240 – 1440 | Configured default |
style | candle · line · area · heikinashi | Configured default |
theme | dark · light | Configured default |
indicators | CSV of EMA, RSI, MACD, VWAP at most 4, deduped | None |
bars | 20 – 1000 | Configured default |
showVolume | true · false · 1 · 0 | true |
raw | true · false · 1 · 0 | Unset the Accept header decides |
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
23 operations behind one route
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.
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
| Operation | Parameters | Moves value |
|---|---|---|
getUserWalletBalanceInfo | symbol | |
getTradMarketInformation | symbol, timeframe, limit, startTime | |
getTradeOrderInformation | orderId, symbol | |
extractTradeInfo | tradinfo | |
createMarketTradeOrderBx | symbol, side, type, amount, price, timeforce, stopPrice, clientOrderId | Yes moves value |
cancelMarketTradeOrderBx | orderId, symbol | Yes moves value |
transferUsertradeProfits | amount, symbol | Yes moves value |
Futures 16 operations
| Operation | Parameters | Moves value |
|---|---|---|
getUserWalletBalanceInfo | symbol | |
getMarketContractPosition | symbol | |
getContractOrderInformation | orderId, symbol | |
getMarketContractTradeState | symbol, OrderIds { stopId, takeProfitId } | |
getContractMarketInformation | symbol, timeframe, limit, startTime | |
getContractMarketOpenInterest | symbol, timeframe | |
extractTradeInfo | tradinfo | |
createMarketContractOrder | symbol, side, type, quantity, price, timeforce, stopPrice, reduceOnly, positionSide, closePosition, workingType, clientOrderId | Yes moves value |
createBracketContractOrder | symbol, side, type, quantity, stopLoss, takeProfit, price, clientOrderId | Yes moves value |
closeMarketContractPosition | symbol | Yes moves value |
cancelMarketContractOrder | orderId, symbol | Yes moves value |
cancelAllMarketContractOrder | symbol | Yes moves value |
setMarketContractLeverage | symbol, leverage | Yes moves value |
setMarketContractMarginType | symbol, marginType | Yes moves value |
setMarketContractPositionMode | positionMode | Yes moves value |
transferUsertradeProfits | amount, symbol | Yes moves value |
Three granularities
Compiled · one timeframe · one read
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=klineor?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.
Read keys
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
Five GET-only routes
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.
| Route | Query | Ordering |
|---|---|---|
/klines/{platform}/{symbol}/{timeframe} | limit, before | Oldest first |
/trades/{platform}/{symbol} | limit | Oldest first, newest last |
/depth/{platform}/{symbol} | levels | Bids descending, asks ascending |
/depth/{platform}/{symbol}/history | limit, before | Oldest first |
/footprint/{platform}/{symbol} | limit, before | Oldest first |
/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
Tracked locally, canonical across venues
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.
| Method | Route | What it does |
|---|---|---|
| GET | /api/{mt}/orders | Working orders. Query: platform, symbol, limit |
| GET | /api/{mt}/orders/history | Completed orders, newest first. Query: before, limit, query |
| DELETE | /api/{mt}/orders/history | Clear the local history. The exchange is not touched. Write scope, and the confirmation header |
| POST | /api/{mt}/orders/refresh | Re-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
13 analytics route shapes, across this section and the next
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.
| Method | Path | What it does |
|---|---|---|
| GET | /api/indicators | The 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/compute | Compute 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}/structure | Derived market structure for one timeframe. Query: symbol, platform?, timeframe, limit?, left?, right?, lookback?, atrMult?, atrPeriod?, tolerancePct? |
| GET | /api/{mt}/orderflow-metrics | Orderflow metrics computed from the live book. Query: symbol, platform?, levels?, obiDepth? |
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
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.
# 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'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
41 series functions · 10 bar fields · parsed, never evaluated
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.
| Method | Path | What it does |
|---|---|---|
| GET | /api/indicators/custom/guide | The 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/custom | Every custom indicator this engine has stored |
| GET | /api/indicators/custom/{id} | One stored definition |
| POST | /api/indicators/custom/validate | Check 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/preview | Run 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/custom | Store 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 |
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.
{ "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" } ]}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
| Limit | Value | What it bounds |
|---|---|---|
maxFormulaChars | 2000 | The longest a single formula may be |
maxParams | 8 | Parameters a definition may declare |
maxOutputs | 8 | Outputs a definition may declare |
maxIndicatorCalls | 24 | Calls to built-in indicators across the whole definition |
maxBars | 5000 | Bars one preview or compute may be asked for |
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.
# 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.jsonStrategies
14 global route shapes · a new version on every write
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.
| Method | Path | What it does |
|---|---|---|
| GET | /api/strategies/schema | The definition schema, in sections. Query: section = overview | inputs | conditions | exit | risk | meta | all, defaulting to the overview |
| GET | /api/strategies/examples | Worked definitions, plus the analytics outputs they assume are available |
| POST | /api/strategies/validate | Check 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/explain | Explain an unsaved draft in prose, so an author can confirm the engine read it the way it was meant |
| GET | /api/strategies | Every stored strategy, with its current version and the versions behind it |
| POST | /api/strategies | Store 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}/versions | The version history |
| GET | /api/strategies/{id}/versions/{n} | One version, exactly as it was stored |
| POST | /api/strategies/{id}/refine | Apply a list of operations and record why. Body: ops (required), changeSummary?, origin? |
| GET | /api/strategies/{id}/diff | What changed between two versions. Query: from, to |
| GET | /api/strategies/{id}/explain | Explain a stored strategy in prose. Query: version |
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.
{ "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": "…" }}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.
# 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'Backtests
7 route shapes · queued, not blocking
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.
| Method | Path | What it does |
|---|---|---|
| POST | /api/{mt}/backtests | Queue 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/backtests | Every job. Query: strategyId, status, limit |
| POST | /api/backtests/compare | Compare 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}/report | Remove the job and its report permanently. A job that is still live is a 409; cancel it first |
| POST | /api/backtests/score-trades | Score a set of trades you already hold, with no run at all. Body: trades (required), equityCurve?, initialEquity?, quoteCurrency? |
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.
# 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'Statuses
Three of the five are terminal: done, failed and cancelled. A job in any of them will never move again.
Progress
| Phase | What is happening |
|---|---|
loading_history | Bars are sourced for the market and range you asked for |
computing_indicators | Every indicator the definition refers to is computed once, across the whole range |
simulating | Bars are walked forward and positions are opened, managed and closed |
scoring | Trades become metrics, curves and the breakdowns the report is made of |
Defaults worth knowing
| Parameter | Default | Note |
|---|---|---|
fees.takerPct | 0.04 | A PERCENT, not basis points. The default means four hundredths of one percent per side, and passing 4 here would charge you four percent |
slippageBps | 1 | Basis points, applied against you on both entry and exit |
initialEquity | 10000 | Denominated in the quote currency |
quoteCurrency | USDT | What equity, PnL and notional are reported in |
range.bars | 1000 | Bars used when no explicit range is given. The ceiling is 5000 |
closeAtEnd | false | Positions 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 |
Trade journal
11 global route shapes · captured or written by hand
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.
| Method | Path | What it does |
|---|---|---|
| GET | /api/journal/entries | List entries, newest page first. Query: from, to, symbol, platform, marketType, status, origin, direction, strategyId, setup, tag, offset, limit (at most 500) |
| POST | /api/journal/entries | Write 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}/attachments | Append attachments to an entry, leaving the existing ones alone. Body: attachments, or a single attachment |
| GET | /api/journal/stats | Aggregate performance over the same filters the list takes, plus initialEquity |
| GET | /api/journal/calendar | A dense month grid. Query: month (required, YYYY-MM), timezoneOffsetMinutes, plus the list filters |
| GET | /api/journal/daily | The same numbers rolled up per day rather than per month. Query: timezoneOffsetMinutes, plus the list filters |
| POST | /api/journal/import | Import many entries at once. All of them are accepted or none are |
| GET | /api/journal/status | Whether the journal is enabled and running, and whether it is capturing from the engine’s own order stream |
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.
{ "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": "…" } ]}The closed sets
| Field | Values |
|---|---|
origin | manual · captured |
status | open · closed |
direction | long · short |
mood | calm · anxious · confident · fomo · revenge · bored |
entryGrade · exitGrade | A · B · C · D |
attachments[].kind | chartshot · image · link |
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.
# 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":"…"}]}'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
3 route shapes · live only, nothing simulated
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.
| Method | Path | What it does |
|---|---|---|
| GET | /api/{mt}/market-events | Scored 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/types | The type catalogue and the urgency thresholds, so a client never hardcodes either |
| GET | /api/market-events/status | Whether detection is running, which pairs it is attached to, and what it has seen |
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.
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:
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.
# 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/statusWebhook subscriptions
One predicate, one endpoint
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.
| Method | Route | Body / query |
|---|---|---|
| POST | /api/subscriptions | rule, 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/events | since, 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 code | HTTP | Envelope code |
|---|---|---|
invalid_request | 400 | validation_error |
not_found | 404 | not_found |
limit_reached | 429 | too_many_requests |
forbidden_url | 403 | forbidden_url |
duplicate | 409 | duplicate |
disabled | 503 | service_unavailable |
Rule grammar
Five address fields, one operator, one value
Exactly one predicate per rule there is no AND, OR or NOT. The same string is accepted verbatim over MCP.
<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.
| Operator | Value | Semantics |
|---|---|---|
> < >= <= | number | True for as long as the level comparison holds |
== != | number or string | Strict and type-aware no coercion, so the string "46" never equals the number 46. Strings compare case-sensitively after trim |
crosses_above | number | Fires on the transition upward through the threshold, not while above it |
crosses_below | number | Fires on the transition downward through the threshold |
enters | string | Fires on the transition into that state |
exits | string | Fires on the transition out of that state |
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
HMAC-SHA256 over the raw bytes
A fired rule POSTs JSON to your endpoint with a delivery id, a monotonic cursor and, when you supplied a secret, a signature.
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 secretThe 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.
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);}Event log
Durable, ordered, replayable
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.
# 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