Skip to content

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

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.


{
"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.

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.


Every node has a unique id and a type.

{
"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. market is required.
  • scope: "global" — reads the venue-wide cross-section. market is 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.
{
"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.

{
"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.

{
"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.

{
"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.


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.


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[].nodeId names a node that exists in nodes.
  • The graph is acyclic.
  • Every outputs[].node names a node that exists and is of type activation, 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.


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"
}
]
}

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"
}
]
}

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.

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.