Skip to content

Building a strategy programmatically

A strategy is a script: a program in the pnl script language, stepped once per 1m bar, or once per longer bar it declares with every. It declares the markets and channels it reads, a warmup, its searchable params and its memory, then emits a bounded target exposure w_t ∈ [-1, 1] for each market it holds. The compiler computes its worst-case cost per evaluation and proves it within the cap before it runs.

All strategies share a 1× total gross baseline across their traded markets. If the sum of absolute held targets exceeds one, all targets scale down together; smaller targets are never scaled up. A copier’s displayed leverage multiplies this baseline, so Allocation × Leverage is the maximum total gross notional. The script language has no leverage parameter.

Emitted targets receive no additional platform deadband in backtests, paper or live signals. Put optional deadbands, smoothing or stepped sizing in ordinary script parameters and target logic. NaN holds the previous target; zero requests an exit. Venue minimum orders and account drift checks still apply. Test remembered state across rolling live windows and restarts; arbitrary kept memory is reconstructed from input history, not persisted between evaluations.

  • This document: GET /api/v1/contract/skills.md
  • Script language, by section: GET /api/v1/reference/script (the index) and GET /api/v1/reference/script/<section> (one section, Markdown)
  • The same sections on the docs site: https://docs.tradepnl.xyz/reference/script/index.md and https://docs.tradepnl.xyz/reference/script/<section>.md
  • Editing a draft’s script as an external agent: https://docs.tradepnl.xyz/agents/skills/
  • Authoritative check: POST /api/v1/backtests compiles the script and refuses it with the compiler’s diagnostics (line, column, code, hint).

Read the index first and fetch only the sections you need: a-program, manifest, every, statements, arrays, keep, window and params cover most strategies, and templates lists a reference program for each model family.


1. Every window is a duration, never a bar count

Section titled “1. Every window is a duration, never a bar count”

warmup 7d, window h[markets] 1d, param n = 30d. Durations are converted to bars of the program’s stride at compile time, so a duration means the same span of time at every stride. A plain number counts bars of the stride.

every <1m|5m|15m|1h|4h|1d> [at <N>m] sets how often the program runs; without it, the program runs every 1m. The program runs once per bucket, as its last minute closes, and each channel is that bucket’s bar. Buckets are aligned to UTC; at shifts them by whole minutes, less than the stride: every 1h at 17m runs on hours that start at :17. time is the bucket’s start.

  • Durations count strides: 24h under every 1h is 24 bars. A duration that is not a whole number of strides (30m under every 1h) is refused, never rounded.
  • One evaluation may spend evaluationFuelCap(stride): min(stride in minutes, 15) × 2,000,000 fuel (1m: 2,000,000, 5m: 10,000,000, 15m: 30,000,000, 1h: 30,000,000, 4h: 30,000,000, 1d: 30,000,000).
  • Neither the stride nor the offset is a param, and no search tunes them. They pick among a few discrete clocks; a different offset is a different set of bars, not a neighbour the evaluation’s perturbations can compare. Running one idea at several clocks is still a search: each run is a trial of the same family.
  • The full evaluation, the tradeability gate, always runs on 1-minute data: the program decides once per bucket and its targets are held, filled, funded and liquidated minute by minute until the next decision, as paper and live trading do. A bucketed backtest simulates on the stride’s own bars, one decision per bar with liquidation judged against each bar’s full range; it is faster for exploring, approximates the P&L of trading the program, and is never what the gate measures.

A missing bar arrives as NaN. Every comparison with NaN is false, reductions propagate it, and emitting NaN HOLDS the position. Use isnan(x) wherever “no reading” and “a reading of zero” should differ.

Backtests and evaluations can begin at the earliest available held market in the basket. Later-listed markets read NaN before their history starts. Check readiness and indicator warmup per market; the global warmup does not make a later market ready. Emit nan until its inputs are ready. Its target starts at zero, so it does not trade before its first instruction. After entry, NaN preserves the prior target; emit 0.0 to request an exit. NaN-skipping reductions such as nanmean explicitly choose to use fewer observations. See the reference’s values section for a BTC/HYPE example with separate readiness for each market.

4. A script is inference; the platform does the fitting

Section titled “4. A script is inference; the platform does the fitting”

A script is a model: you write its inference, a statistical, machine-learning, topological or linear-algebra computation over the bars, from the language’s deterministic primitives. The constants that would be fitted are declared as params with bounded ranges, param gain = 1.0 in (0.25, 4.0): a ridge penalty, a kernel bandwidth, a learning rate, a window length, a number of clusters. The params and their ranges define the search problem, and the platform’s backtest search fits them by Bayesian optimisation over those ranges. Every trial it runs is counted by the tradeability gate.

Trading data is noisy and in-sample fitting overfits easily, so fitting is kept where it is counted. The language has primitives for evaluating models and none for training them: no automatic differentiation, no backpropagation, no grad. Updates a model makes as it runs (a Kalman filter, recursive least squares, a Cholesky update, a gradient step on the previous bar) are part of its inference: walk-forward, causal, priced like any other instruction, and they cost no trials. A model trained elsewhere brings its weights in as params.

Evaluation perturbs bounded params, and a script without one cannot pass the gate. Every param element counts as a tunable coordinate for the gate’s trial accounting, and so does every distinct literal constant of an array literal in the body: fitted weights pasted into the body are still fitted weights. Structure does not count: -1, 0 and 1 (signs, masks, one-hot rows), an exact index permutation 0..n-1, and non-literal elements (market inputs, param references, expressions).

The reference holds one whole program per model family, its hyperparameters already declared as bounded params: template-pca, template-sfa, template-cca, template-kernel-ridge, template-gp, template-svm, template-kmeans, template-knn, template-logistic, template-ridge-lasso, template-softmax, template-mlp, template-gmm, template-kalman, template-hmm, template-bayes-linear, template-sampling. Fetch templates for what each one models, copy the closest, then change its inputs and its signal and keep its ranges honest.

Every market a script emits to is one it may hold. Legs share one equity and one gross capacity: when the summed |w_t| would exceed the cap, every leg is scaled down together — capacity is shared, not multiplied.


The smallest complete strategy: a trend reading kept in two EMAs, a bound on how hard it can push, and the market it drives. Two bounded params give the evaluation’s parameter perturbations something to perturb.

markets ETH
channels close
warmup 2d
param gain = 50.0 in (10.0, 200.0)
param fast = 0.01 in (0.001, 0.1)
keep f
keep s
step:
let p = close[ETH]
if not isnan(p):
if s == 0.0:
f = p
s = p
f = f + fast * (p - f)
s = s + 0.001 * (p - s)
emit ETH, tanh(gain * (f - s) / maximum(s, 0.000000000001))

One spread drives two opposed legs. This is NOT two strategies: the legs share an account and the gross capacity, and someone holding the algo holds the relationship rather than either side of it. The window holds a day of the spread, so the z-score is exact and NaN (holding) until the day is full.

markets BTC, ETH
channels close
warmup 1d
param gain = 1.0 in (0.25, 4.0)
window spread 1d
step:
push(spread, log(close[ETH] / close[BTC]))
let z = (spread[-1] - mean(spread[:])) / maximum(std(spread[:]), 0.000000000001)
emit ETH, -tanh(gain * z)
emit BTC, tanh(gain * z)

Several markets as vectors, with a benchmark it does not trade

Section titled “Several markets as vectors, with a benchmark it does not trade”

close[:] is this bar’s closes across the declared markets, in sorted order. Each alt’s day return is measured against BTC’s, which the program reads but never emits for, so BTC is a signal market and not a holding.

markets BTC, ETH, SOL
channels close
warmup 1d
param gain = 20.0 in (5.0, 80.0)
window hist[markets] 1d
step:
let px = close[:]
let r = px / hist[-1440] - 1.0
push(hist, px)
emit ETH, tanh(gain * (r[ETH] - r[BTC]))
emit SOL, tanh(gain * (r[SOL] - r[BTC]))

every 1h runs the program once per hourly bar, as the hour closes, and every channel is that hour’s bar. The window still says what it means: 1d under every 1h is 24 bars. The target holds until the next hour’s decision.

markets ETH
channels close
every 1h
warmup 2d
param gain = 1.0 in (0.25, 4.0)
window hist 1d
step:
push(hist, log(close[ETH]))
let z = (hist[-1] - mean(hist[:])) / maximum(std(hist[:]), 0.000000000001)
emit ETH, -tanh(gain * z)

The band filters each market’s target in the script; zero exits bypass it and NaN holds. The hourly reset and two-hour warmup make this example reconstructible in rolling live windows. Keep state is not durable between independent evaluations; preserve that reset or test a replacement.

markets BTC, ETH
channels open, close, time
warmup 2h
param band = 0.05 in (0.01, 0.2)
keep previous[markets]
step:
let desired = clip((close[:] / open[:] - 1.0) * 10.0, -1.0, 1.0)
if time[BTC] == floor(time[BTC] / 3600.0) * 3600.0:
previous = 0.0
let accept = (abs(desired - previous) >= band) or (desired == 0.0)
previous = where(isnan(desired), previous, where(accept, desired, previous))
emit markets, where(isnan(desired), nan, previous)

How to research a strategy:

  1. Explore cheaply with backtests and studies.
  2. Check promising candidates periodically with a fast evaluation. It is advisory feedback, not the gate.
  3. Once a candidate is solid, iterate with full evaluations. The full evaluation is the real gate and part of the feedback loop, not a one-off final step: read its verdict, improve the candidate, evaluate again.
  4. Every run (backtest, fast evaluation or full evaluation) is a trial of the strategy’s family and costs credits at the live price book.

On the Build page, Backtest, Fast evaluate and Full evaluate are one click each with no confirmation dialogs, and each button has an info popup showing its live price from GET /api/v1/prices. The native agent calls fast_evaluate and full_evaluate directly, with no confirmation step. Runs are paid from compute credits (reserve, then charge or release) and are bounded by the credit balance and, for the native agent, the conversation’s compute budget; when a run cannot proceed, it returns 402 insufficient_credits or quota_*. An external agent working with a user’s API key asks the user before starting a full evaluation: it costs far more than a backtest.

One API for everything a strategy is built with: drafts and the draft’s active script, the draft’s workspace (named, versioned scripts with their runs and evaluations, studies, notes), proposals, backtests, fast and full evaluations, markets and candles, and credits. The native agent in the Build page calls exactly these routes for its user; an agent CLI calls them with an API key.

Auth. Authorization: Bearer pnl_sk_… (the user creates a key under Agent CLI in the editor’s agent chat menu). A key is its account on the whole API and pays from that account’s credits; a drafts key reads and edits drafts only and cannot spend. Anonymous calls are refused 401. Listing an algo (POST /api/v1/algos) stays with the signed-in wallet.

Credits and the 402. GET /api/v1/prices (public) is the live price of a backtest, a fast and a full evaluation, in credits; GET /api/v1/me/credits is the balance. A paid run the balance cannot cover is refused before it runs, with HTTP 402 and one body on every route: {"error":"insufficient_credits","message","required","balance","topUp":"/credits"} (whole credits of $0.01). A run that fails is not charged. Every run is a trial of its family.

Workflow.

  1. Create a draft (POST /api/v1/drafts) or pick one (GET /api/v1/drafts).
  2. Edit its active script (PATCH /api/v1/drafts/{id}/script, compile first, on baseRevision).
  3. Backtest it (POST /api/v1/backtests with {"draftId"}, then collect it from /api/v1/backtests/{id}).
  4. Iterate in the workspace: save variants as named scripts (PUT …/workspace/scripts/{name} with requireCompile), backtest them by name ({"draftId","script":{"name"}}), keep notes and studies.
  5. Check a promising script with a fast evaluation (advisory), then iterate with full evaluations (the gate), both as {"draftId","script":{"name"}}: each verdict attaches to the version it evaluated.
EndpointWhat it does
GET /api/v1/draftsThe account’s drafts.
POST /api/v1/draftsCreate a draft: {name, script, venue}.
GET /api/v1/drafts/:draftOne draft: name, program, revision, venue.
PUT /api/v1/drafts/:draftSave any program (the editor’s autosave): {name, script, baseRevision}.
DELETE /api/v1/drafts/:draftDelete a draft.
GET /api/v1/drafts/:draft/scriptThe draft’s active script (the editor’s program) with its revision, markets and compile diagnostics.
PATCH /api/v1/drafts/:draft/scriptEdit the active script, compile first: `{baseRevision, script
GET /api/v1/drafts/:draft/workspace/statusStorage, files, object counts and running jobs against the workspace limits.
GET /api/v1/drafts/:draft/workspace/scriptsWorkspace scripts, newest first, with their run numbers and evaluation verdicts (?passed=1: passed only).
GET /api/v1/drafts/:draft/workspace/scripts/:nameOne script with its history (?sha= a past version).
GET /api/v1/drafts/:draft/workspace/scripts/:name/resultThe full result attached to a script’s version (404 when it ran in summary only).
GET /api/v1/drafts/:draft/workspace/scripts/:name/evaluationA script’s evaluation: `?kind=fast
PUT /api/v1/drafts/:draft/workspace/scripts/:nameSave a script version: {source, baseSha?, requireCompile?}; requireCompile refuses a program that does not compile.
PATCH /api/v1/drafts/:draft/workspace/scripts/:nameFind/replace edits to a script’s current version as a new version: {edits, baseSha, requireCompile?}.
DELETE /api/v1/drafts/:draft/workspace/scripts/:nameDelete a script name with its result and evaluations.
GET /api/v1/drafts/:draft/workspace/studiesOptimisation studies.
GET /api/v1/drafts/:draft/workspace/studies/:studyOne study: its definition, trials and revisions.
PUT /api/v1/drafts/:draft/workspace/studies/:studyWrite a study: {study, meta, revision, trials, trialsRevision} (compare-and-set on the revisions).
DELETE /api/v1/drafts/:draft/workspace/studies/:studyDelete a study (its scripts stay).
GET /api/v1/drafts/:draft/workspace/notesNotes.
GET /api/v1/drafts/:draft/workspace/notes/:nameOne note with its revision.
PUT /api/v1/drafts/:draft/workspace/notes/:nameWrite a note: {content, baseRevision?} (0 creates only).
PATCH /api/v1/drafts/:draft/workspace/notes/:nameFind/replace edits to a note over revision: {edits, revision}.
DELETE /api/v1/drafts/:draft/workspace/notes/:nameDelete a note.
GET /api/v1/drafts/:draft/workspace/activityActivity, newest first (?since=&before=&limit=).
GET /api/v1/drafts/:draft/proposalsEdit proposals to the active script (?status=).
POST /api/v1/drafts/:draft/proposalsThe native agent proposes a change to the active script (compile first): `{summary, edits
POST /api/v1/drafts/:draft/proposals/:proposal/acceptThe owner accepts a proposal.
POST /api/v1/drafts/:draft/proposals/:proposal/rejectThe owner rejects a proposal: {comment?}.
POST /api/v1/backtestsQueue a backtest: {script: {source, name}} inline, or {draftId, script?: {name}} for a workspace script (none: the active script); store: true keeps the full result. 202 {id}. Paid.
GET /api/v1/backtests/:idThe result, once: waits up to waitMs (≤ 60 s), 202 while it runs (`?detail=summary
POST /api/v1/trials/previewWhat planned more runs do to the family’s trial count at the gate: {script, planned}. Free.
POST /api/v1/evaluations/fastQueue the advisory fast evaluation: {script} inline, or {draftId, script: {name}} (its summary attaches to the script). 202 {id}. Paid.
GET /api/v1/evaluations/fast/:idThe fast evaluation, once: waits up to waitMs (≤ 60 s), 202 while it runs (`?detail=summary
GET /api/v1/evaluations/full/availabilityWhether full evaluation is available.
POST /api/v1/evaluations/fullThe gate: {script} or {draftId, script: {name}, reference?} (the verdict attaches to the script). 202 {jobId}. Paid.
GET /api/v1/evaluations/fullThe account’s full evaluations.
GET /api/v1/evaluations/full/:idOne full evaluation: status, progress, verdict.
GET /api/v1/marketsThe venue’s markets (?venue=).
GET /api/v1/candlesRecent candles: ?venue&market&from&to&interval (unix seconds; interval 60/300/900/3600/14400/86400).
GET /api/v1/pricesThe live price book: backtest, fast- and full-evaluation prices in credits. Public.
GET /api/v1/me/creditsThe account’s credit balance.
GET /api/v1/me/credits/statementThe account’s credit statement.

Prices come from the live price book: GET /api/v1/prices (public) gives the backtest, fast-evaluation and full-evaluation prices in credits. Every run is charged to the account (GET /api/v1/me/credits is its balance). A balance that cannot cover a run is refused before it runs with HTTP402 and one body on every route: {"error":"insufficient_credits","message","required","balance","topUp":"/credits"} (whole credits of $0.01); a run that fails is not charged.

Four operations with separate responsibilities.

Backtest (priced by the price book): POST /api/v1/backtests with {"script": {source, name}} and optionally "venue", or {"draftId"} to run a draft’s own program on its venue ("script": {name} with it runs a script of the draft’s workspace). It answers 202 {id}; a GET of /api/v1/backtests/{id} waits up to a minute, answers 202 while it waits (status queued until a worker starts it, then running) and the result once (?detail=summary for the numbers only). Simulation only, on public history: metrics and the equity curve, all net of platform fees, venue base-tier fees, funding and modelled slippage, plus the fees paid, a trade summary and attribution: per-market daily price P&L, funding and cost, with a residual so each day sums to its net return. Use it to see which markets earn and which only pay fees. capacity estimates the capital the trading fits into at recent venue liquidity: comfortable and maxOut, halfEdge/zeroEdge where modelled market impact halves or erases the net return, and the bindingMarket; it is shared by every allocation. Diagnostics are advisory and uncalibrated; no acceptance threshold or gate preview is implied. It returns mode: "simulation", validation: "not_run" and no gate verdict, with every (the program’s stride and offset) and timeframe, the bars it was simulated on: the program’s stride, so window.warmup and the curve count those bars. Full mode is refused.

Platform fees are set by the platform, not by the script. With T the algo’s average daily turnover of its capital, the fee is 5 bps up to one turnover a day, then 5 / T bps, never below 0.1 bps, rounded down to the venue’s fee unit. T is measured on the algo’s published targets, so no wallet’s trading changes another’s rate, and recent activity counts more than older activity. Backtests, evaluations and paper charge this fee plus each venue’s official base-tier fees (Hyperliquid maker 1.5 / taker 4.5 bps, Lighter maker 0 / taker 0 bps); wallet referral discounts are separate. Do not subtract another fixed fee from net results.

Backtests optionally accept "window": {"from": <UTC seconds>, "to": <UTC seconds>}. The half-open bounds must be minute-aligned. The start must be within signed history of at least one held market; the end cannot exceed any held market’s published end. Every held symbol must exist in the dataset. A later-listed market may be NaN throughout an older requested window. They include warmup: prepend the script’s warmup to your intended scoring start. An invalid or too-short range is refused before charging. Without window, the full available history is used. Check returned window.from/to and include dates in local result-cache keys. Changing dates still counts trials in the same family; it does not create validation evidence. Custom windows are not supported for evaluations.

  1. Backtest every idea, alternative and revision. Reject or fix obviously broken candidates: a complete capital loss, liquidation, no trading or costs overwhelming the idea. Explain the issue; avoid blind parameter sweeps. Positive returns are not evidence of robustness.
  2. Every admitted, valid backtest counts as a TRIAL of its strategy family (the program with its numbers stripped), for your wallet. Full evaluation receives the observed count; it is not proof of complete external search history. Keep your local trials too. Backtesting does not reset this count.

Fast evaluation (advisory): POST /api/v1/evaluations/fast accepts the same script and venue as backtest and gives advisory feedback at a small budget. Its tradeability says whether the net curve is tradeable long or short after fees. It is advisory, never a probability of approval: the full evaluation decides. It is queued like a backtest: the POST answers 202 {id, status: "queued"} and the run continues if the connection closes; collect it once with GET /api/v1/evaluations/fast/:id, which waits up to waitMs (≤ 60 s) and answers 202 while it runs. The result is {status:"complete", envelope, worker, acceptedAt}, where envelope is the worker’s signed, losslessly encoded result; add ?detail=summary to receive the decoded numbers instead. It is charged when collected; a failed run is released. The native agent follows the same workflow with its fast_evaluate and full_evaluate tools, and calls both directly, paid from the user’s credits.

Full evaluation (paid, the gate; hosted only, when available): check GET /api/v1/evaluations/full/availability first. Private certification is unavailable until an approved policy configuration and all required methods are available. A configuration digest is not empirical calibration of approval probability. Unavailable work reserves no credits. When available, submit to POST /api/v1/evaluations/full and poll the returned job. A completed statistical decision settles the reserved price; infrastructure or unavailable methods refund it. Public decisions contain passed, complete, category and nullable evidence bands; private reports and registry details are not exposed. Launch: after full evaluation passes, prepare the name, symbol, blurb, venue and returned jobId as verdictId through POST /api/v1/algos/prepare. The signed-in owner approves a 5 USDC transfer from the selected venue to its launch receiver. Send the same listing fields and paymentReference to POST /api/v1/algos; 202 means payment is pending and the same request can be retried without paying again. The authenticated owner alone can consume this strategy-bound, unexpired certificate once. Invalid metadata does not authorize a listing or spend another full evaluation. The script editor can go directly from inspecting a backtest to its Evaluate button. Operator Demo is separate: signed public advisory evidence does not become certification. Backtest, fast-evaluation and full-evaluation have independently bounded hosted queues. HTTP503 queue_overloaded includes retryAfterMs; wallet/API limits still apply. An API key (Authorization: Bearer pnl_sk_…) is its account on the whole Backtest workspace API: drafts and the draft’s own program, its workspace and proposals, backtests, fast and full evaluation, markets, the trial preview and the credit balance, paid from that account’s credits; an edit shows live in the owner’s open editor. A drafts-scoped key reads and edits drafts only and cannot spend credits. Listing (POST /api/v1/algos) stays with the signed-in wallet. Do not export the owner’s session cookie into an agent prompt. On HTTP429 or HTTP503, wait for retryAfterMs rather than retrying at once; every run is a trial, so fewer, better-considered runs are the intended use. Trial listings remain copyable; Proven criteria are governed separately.