Building a strategy programmatically
Generated from the engine. The indicator tables below ARE the catalogue the validator checks against — if a sensor is listed here, the engine can evaluate it, and if it is not listed, the engine will refuse it.
A strategy is a directed acyclic graph. Sensors read market data, transforms
combine and shape them, and each output binds one market to a bounded target
exposure w_t ∈ [-1, 1]. The platform runs it on 15m bars.
You produce one JSON document. Validate it against the schema, then POST it to be checked properly, then backtest it.
- Schema:
GET /api/v1/contract/strategy.schema.json(https://pnl.xyz/schema/pnl.strategy.v1.json) - This document:
GET /api/v1/contract/skills.md - Indicators as JSON:
GET /api/v1/contract/indicators - Authoritative check:
POST /api/v1/contract/validate
Two rules that are not style
Section titled “Two rules that are not style”1. Every number is a fixed-point integer at 1e6
Section titled “1. Every number is a fixed-point integer at 1e6”0.65 is written 650000. Multiply by 1000000 and round.
This is not a preference. The strategy hash is what a launch attestation signs,
so two hosts that disagree about the bytes disagree about whether the strategy
exists. A float round-trips through JSON as "0.65" in one host and
"0.6500000000000001" in another after a single arithmetic step — and one ulp
is exactly what a slider dragged twice produces. Integers have one spelling.
Applies to: leverageCap, deadband, adjustmentRate, weight,
threshold, gain.
2. Every window is a duration, never a bar count
Section titled “2. Every window is a duration, never a bar count”"7d", "3h", "180m", "2w". One number, one unit — "6h30m" is not a
duration and will be refused.
window: 168 means 7 days at 1h bars and 42 hours at 15m. A bar count
silently redefines a strategy the moment the platform’s bar size changes. A
duration means the same thing at any bar size.
A window must be at least one 15m bar, or it would round to a single bar and mean something other than what was written.
The document
Section titled “The document”{ "schema": "pnl.strategy.v1", "name": "string", "leverageCap": "fixed-point, > 0", "deadband": "fixed-point, >= 0", "adjustmentRate": "fixed-point, in (0, 1e6]", "nodes": [ "…" ], "outputs": [ { "market": "ETH", "node": "id of an activation node" } ]}| Field | Meaning |
|---|---|
leverageCap |
Maximum gross leverage. Shared across every leg — a two-legged strategy at full conviction is levered like a one-legged one, not twice as hard. |
deadband |
Leave the position alone while it is within this fraction of target. At 15m bars this is most of what separates a signal from pure turnover. |
adjustmentRate |
Fraction of the gap to target closed each bar. 1000000 moves the whole way; a smaller value approaches gradually and trades less. |
outputs |
One entry per market traded. At least one, and a market appears at most once. |
Several outputs are one algo, not several
Section titled “Several outputs are one algo, not several”Legs share one equity and one leverage cap. That is what makes a pair trade a
single thing someone can hold: the legs are not independently meaningful, and
what is being traded is the relationship. When the summed |w_t| across legs
would exceed the cap, every leg is scaled down together — capacity is
shared, not multiplied.
Node types
Section titled “Node types”Every node has a unique id and a type.
indicator — reads data
Section titled “indicator — reads data”{ "id": "trend", "type": "indicator", "scope": "local", "market": "ETH", "indicator": "EMA_SPREAD", "params": { "fast": "3h", "slow": "12h" }, "zscore": "7d"}scope: "local"— reads one market’s own series.marketis required.scope: "global"— reads the venue-wide cross-section.marketis forbidden, because a global sensor is the cross-section and naming a market would suggest it reads one series.params— durations, per the tables below. Exactly what the indicator takes.zscore— optional rolling window. Rescales the sensor to standard deviations, which is what makes it comparable across markets and across regimes. Most sensors want one.
linear_combination — weighted sum
Section titled “linear_combination — weighted sum”{ "id": "mix", "type": "linear_combination", "inputs": [ { "nodeId": "trend", "weight": 650000 }, { "nodeId": "carry", "weight": -350000 } ]}Terms are summed in sorted nodeId order regardless of how you list them,
so the last bit of the result does not depend on the order you happened to
write. Needs at least one input.
deadband — ignore small signals
Section titled “deadband — ignore small signals”{ "id": "band", "type": "deadband", "input": "mix", "threshold": 150000}Output is 0 where |x| < threshold. This is a signal deadband; the
strategy-level deadband field is an execution one. Both reduce turnover;
this one changes the signal, that one changes when you act on it.
smooth — EMA
Section titled “smooth — EMA”{ "id": "calm", "type": "smooth", "input": "band", "halfLife": "2h"}Effectively mandatory at 15m: single-bar indicator noise is what turns a signal into turnover, and turnover at 15m is what eats the account.
activation — bound to [-1, 1]
Section titled “activation — bound to [-1, 1]”{ "id": "target", "type": "activation", "input": "calm", "function": "tanh", "gain": 1750000}function is one of tanh, softsign, clamp. gain is
applied before the transfer function: higher gain saturates sooner, so the
strategy reaches full size on a weaker signal.
Every output must be an activation. That is what makes leverageCap a cap
rather than a suggestion.
Indicators
Section titled “Indicators”Local — the traded market’s own series (24)
Section titled “Local — the traded market’s own series (24)”| Indicator | Parameters | Defaults | What it measures |
|---|---|---|---|
EMA_SPREAD |
fast, slow |
fast: "3h", slow: "12h" |
EMA(fast) − EMA(slow), divided by price. Trend, scale-free. |
MACD |
fast, slow, signal |
fast: "180m", slow: "390m", signal: "135m" |
The spread minus its own EMA: the rate the trend is changing. |
RSI |
window |
window: "1d" |
Wilder’s RSI, rescaled to [-1, 1]. Mean reversion. |
BOLLINGER_PCT_B |
window |
window: "1d" |
Position within the Bollinger band. Stretch, in σ units. |
FUNDING_ZSCORE |
window |
window: "7d" |
Funding rate as a z-score. Crowding, and what it costs to hold. |
ATR_PCT |
window |
window: "1d" |
Average true range over price. Volatility including the gaps. |
DONCHIAN_PCT |
window |
window: "1d" |
Position in the trailing high/low channel, -1 at the low. |
REALIZED_VOL |
window |
window: "1d" |
Standard deviation of log returns, per bar. Not annualised. |
VARIANCE_RATIO |
window, horizon |
window: "7d", horizon: "2h" |
Trending above zero, reverting below. Lo-MacKinlay, centred. |
RETURN_AUTOCORR |
window |
window: "1d" |
Lag-1 autocorrelation of returns. Negative is mean reversion. |
DRAWDOWN |
window |
window: "7d" |
Depth below the trailing high, as a negative fraction. |
RETURN |
window |
window: "6h" |
Log return over the window. Momentum, unsmoothed. |
STOCH_RSI |
rsi, stoch |
rsi: "3h", stoch: "3h" |
Where RSI sits in its own recent range. Turns before RSI does. |
ULTIMATE_OSC |
short, medium, long |
short: "2h", medium: "4h", long: "8h" |
Buying pressure over three horizons at once, weighted 4/2/1. |
TSI |
slow, fast |
slow: "6h", fast: "3h" |
Momentum over its own absolute size. Scale-free, bounded. |
AROON_OSC |
window |
window: "6h" |
How long since the high vs the low. Ordinal, not magnitude. |
VORTEX |
window |
window: "6h" |
Which direction is doing the work, over true range. |
SUPERTREND |
window, multiple |
window: "3h", multiple: "3" |
Trend state with hysteresis. +1 or -1; does not flip on chop. |
KELTNER_PCT |
window, multiple |
window: "6h", multiple: "2" |
Position in an ATR band. Bollinger’s %B, but gaps widen it. |
VWAP_DIST |
window |
window: "6h" |
Distance from rolling VWAP. Where the money actually traded. |
VOLUME_OSC |
fast, slow |
fast: "2h", slow: "12h" |
Is participation expanding. The one sensor that ignores price. |
FORCE_INDEX |
window |
window: "3h" |
Elder’s force, over its own absolute size. Conviction behind a move. |
EASE_OF_MOVEMENT |
window |
window: "6h" |
How far price travelled per unit of volume. |
RELATIVE_VIGOR |
window |
window: "3h" |
Where the close sits INSIDE the bar. Reads open, not just close. |
Global — the venue-wide cross-section (5)
Section titled “Global — the venue-wide cross-section (5)”| Indicator | Parameters | Defaults | What it measures |
|---|---|---|---|
BENCHMARK_MOM |
— | — | The benchmark’s momentum. Precomputed by the feed. |
VENUE_FUNDING_BIAS |
— | — | Funding across the venue: is the whole book long or short. |
MEDIAN_RETURN |
— | — | The cross-sectional median return. What ‘the market’ did. |
VENUE_BREADTH |
— | — | Share of markets advancing. Participation, not size. |
RELATIVE_STRENGTH |
— | — | This market’s return minus the venue median. Outperformance. |
Global sensors are precomputed by the feed over a point-in-time universe. They are not derivable in a client: the client does not have four hundred markets, and a cross-section built from today’s listings would put survivorship bias into a column that every strategy on the platform reads.
Rules the schema cannot check
Section titled “Rules the schema cannot check”Your document can pass JSON Schema validation and still be refused. JSON Schema describes shape; these are referential:
- Node ids are unique.
- Every
input/inputs[].nodeIdnames a node that exists innodes. - The graph is acyclic.
- Every
outputs[].nodenames a node that exists and is of typeactivation, so w_t is bounded to [-1, 1]. - A market appears at most once in
outputs— one position, one claim.
POST /api/v1/contract/validate checks all of them. It is the same validate()
the backtester and the live executor run, so its answer is the real one.
Worked examples
Section titled “Worked examples”One market, one output
Section titled “One market, one output”The smallest complete strategy. A trend sensor, a bound on how hard it can push, and the market it drives. Everything else is this with more nodes.
{ "schema": "pnl.strategy.v1", "name": "ETH trend", "leverageCap": 2000000, "deadband": 50000, "adjustmentRate": 500000, "nodes": [ { "id": "trend", "type": "indicator", "scope": "local", "market": "ETH", "indicator": "EMA_SPREAD", "params": { "fast": "3h", "slow": "12h" }, "zscore": "7d" }, { "id": "smoothed", "type": "smooth", "input": "trend", "halfLife": "2h" }, { "id": "target", "type": "activation", "input": "smoothed", "function": "tanh", "gain": 1500000 } ], "outputs": [ { "market": "ETH", "node": "target" } ]}Two markets, one equity — a pair trade
Section titled “Two markets, one equity — a pair trade”The reason outputs are a list. One spread drives two opposed legs. This is NOT two strategies: the legs share an account, they share the leverage cap, and someone holding the algo is holding the relationship rather than either side of it. A leg on its own is not meaningful.
{ "schema": "pnl.strategy.v1", "name": "ETH/BTC spread", "leverageCap": 2000000, "deadband": 50000, "adjustmentRate": 1000000, "nodes": [ { "id": "eth", "type": "indicator", "scope": "local", "market": "ETH", "indicator": "RETURN", "params": { "window": "6h" }, "zscore": "7d" }, { "id": "btc", "type": "indicator", "scope": "local", "market": "BTC", "indicator": "RETURN", "params": { "window": "6h" }, "zscore": "7d" }, { "id": "spread", "type": "linear_combination", "inputs": [ { "nodeId": "eth", "weight": 1000000 }, { "nodeId": "btc", "weight": -1000000 } ] }, { "id": "band", "type": "deadband", "input": "spread", "threshold": 250000 }, { "id": "long", "type": "activation", "input": "band", "function": "tanh", "gain": 2000000 }, { "id": "flip", "type": "linear_combination", "inputs": [ { "nodeId": "band", "weight": -1000000 } ] }, { "id": "short", "type": "activation", "input": "flip", "function": "tanh", "gain": 2000000 } ], "outputs": [ { "market": "ETH", "node": "long" }, { "market": "BTC", "node": "short" } ]}Mixing a venue-wide sensor with the market’s own
Section titled “Mixing a venue-wide sensor with the market’s own”A global sensor reads the whole cross-section and names no market. It is precomputed by the feed over a point-in-time universe — the client does not have four hundred markets, and a cross-section built from today’s listings would put survivorship bias into a column every strategy on the platform reads.
{ "schema": "pnl.strategy.v1", "name": "ETH carry, gated on breadth", "leverageCap": 3000000, "deadband": 100000, "adjustmentRate": 250000, "nodes": [ { "id": "carry", "type": "indicator", "scope": "local", "market": "ETH", "indicator": "FUNDING_ZSCORE", "params": { "window": "7d" } }, { "id": "breadth", "type": "indicator", "scope": "global", "indicator": "VENUE_BREADTH", "params": {} }, { "id": "mix", "type": "linear_combination", "inputs": [ { "nodeId": "carry", "weight": -700000 }, { "nodeId": "breadth", "weight": 300000 } ] }, { "id": "target", "type": "activation", "input": "mix", "function": "softsign", "gain": 1000000 } ], "outputs": [ { "market": "ETH", "node": "target" } ]}What gets rejected most
Section titled “What gets rejected most”| You wrote | What happens |
|---|---|
weight: 0.65 |
Refused. Fixed-point only: 650000. |
window: 168 |
Refused — that is a bar count. Use "7d". |
"6h30m" |
Refused. One number, one unit. Use "390m". |
"5m" |
Refused — shorter than one 15m bar. |
A local sensor with no market |
Refused. There is no single market to assume. |
A global sensor with a market |
Refused. It reads the cross-section, not a series. |
MACD without signal |
Refused. The catalogue above lists exactly what each takes. |
An output pointing at a deadband |
Refused. Outputs must be activations. |
| Two outputs for one market | Refused. One position, one claim — summing them or taking the last would be a convention nobody chose. |
A leftover targetMarket field |
Refused by the schema. It was a real field, deleted when strategies became multi-market; a document carrying it was written against the old shape. |
After it validates
Section titled “After it validates”Send the strategy and, if you like, the number of configurations you tried. Nothing else is read: fee models and thresholds are the platform’s, and a request that sends its own is ignored rather than obeyed.
POST /api/v1/strategy/backtest runs it and returns metrics plus a gate
verdict. The verdict is four vetoes and a score over six dimensions — see the launch gate.
It is not a formality, and most first attempts do not clear it. A strategy
that trades every bar at 15m usually falls down on cost rather than on
signal; one that was tuned until the numbers looked right usually falls down
on the first veto, which asks whether the signal beats mistimed copies of
itself.
gate.complete tells you whether every input was available when it was
measured. A verdict that is not complete is a preview.