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) andGET /api/v1/reference/script/<section>(one section, Markdown) - The same sections on the docs site:
https://docs.tradepnl.xyz/reference/script/index.mdandhttps://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/backtestscompiles 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.
Rules that are not style
Section titled “Rules that are not style”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.
2. The clock is declared: every
Section titled “2. The clock is declared: every”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:
24hunderevery 1his 24 bars. A duration that is not a whole number of strides (30munderevery 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.
3. NaN is data
Section titled “3. NaN is data”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).
5. Start from a template
Section titled “5. Start from a template”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.
Several outputs are one algo, not several
Section titled “Several outputs are one algo, not several”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.
Worked examples
Section titled “Worked examples”One market, one output
Section titled “One market, one output”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 ETHchannels closewarmup 2dparam gain = 50.0 in (10.0, 200.0)param fast = 0.01 in (0.001, 0.1)keep fkeep 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))Two markets, one equity — a pair trade
Section titled “Two markets, one equity — a pair trade”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, ETHchannels closewarmup 1dparam 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, SOLchannels closewarmup 1dparam 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]))An hourly clock
Section titled “An hourly clock”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 ETHchannels closeevery 1hwarmup 2dparam 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)Script controlled deadband
Section titled “Script controlled deadband”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, ETHchannels open, close, timewarmup 2hparam 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)Research workflow
Section titled “Research workflow”How to research a strategy:
- Explore cheaply with backtests and studies.
- Check promising candidates periodically with a fast evaluation. It is advisory feedback, not the gate.
- 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.
- 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.
The Backtest workspace API
Section titled “The Backtest workspace API”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.
- Create a draft (
POST /api/v1/drafts) or pick one (GET /api/v1/drafts). - Edit its active script (
PATCH /api/v1/drafts/{id}/script, compile first, onbaseRevision). - Backtest it (
POST /api/v1/backtestswith{"draftId"}, then collect it from/api/v1/backtests/{id}). - Iterate in the workspace: save variants as named scripts (
PUT …/workspace/scripts/{name}withrequireCompile), backtest them by name ({"draftId","script":{"name"}}), keep notes and studies. - 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.
| Endpoint | What it does |
|---|---|
GET /api/v1/drafts | The account’s drafts. |
POST /api/v1/drafts | Create a draft: {name, script, venue}. |
GET /api/v1/drafts/:draft | One draft: name, program, revision, venue. |
PUT /api/v1/drafts/:draft | Save any program (the editor’s autosave): {name, script, baseRevision}. |
DELETE /api/v1/drafts/:draft | Delete a draft. |
GET /api/v1/drafts/:draft/script | The draft’s active script (the editor’s program) with its revision, markets and compile diagnostics. |
PATCH /api/v1/drafts/:draft/script | Edit the active script, compile first: `{baseRevision, script |
GET /api/v1/drafts/:draft/workspace/status | Storage, files, object counts and running jobs against the workspace limits. |
GET /api/v1/drafts/:draft/workspace/scripts | Workspace scripts, newest first, with their run numbers and evaluation verdicts (?passed=1: passed only). |
GET /api/v1/drafts/:draft/workspace/scripts/:name | One script with its history (?sha= a past version). |
GET /api/v1/drafts/:draft/workspace/scripts/:name/result | The full result attached to a script’s version (404 when it ran in summary only). |
GET /api/v1/drafts/:draft/workspace/scripts/:name/evaluation | A script’s evaluation: `?kind=fast |
PUT /api/v1/drafts/:draft/workspace/scripts/:name | Save a script version: {source, baseSha?, requireCompile?}; requireCompile refuses a program that does not compile. |
PATCH /api/v1/drafts/:draft/workspace/scripts/:name | Find/replace edits to a script’s current version as a new version: {edits, baseSha, requireCompile?}. |
DELETE /api/v1/drafts/:draft/workspace/scripts/:name | Delete a script name with its result and evaluations. |
GET /api/v1/drafts/:draft/workspace/studies | Optimisation studies. |
GET /api/v1/drafts/:draft/workspace/studies/:study | One study: its definition, trials and revisions. |
PUT /api/v1/drafts/:draft/workspace/studies/:study | Write a study: {study, meta, revision, trials, trialsRevision} (compare-and-set on the revisions). |
DELETE /api/v1/drafts/:draft/workspace/studies/:study | Delete a study (its scripts stay). |
GET /api/v1/drafts/:draft/workspace/notes | Notes. |
GET /api/v1/drafts/:draft/workspace/notes/:name | One note with its revision. |
PUT /api/v1/drafts/:draft/workspace/notes/:name | Write a note: {content, baseRevision?} (0 creates only). |
PATCH /api/v1/drafts/:draft/workspace/notes/:name | Find/replace edits to a note over revision: {edits, revision}. |
DELETE /api/v1/drafts/:draft/workspace/notes/:name | Delete a note. |
GET /api/v1/drafts/:draft/workspace/activity | Activity, newest first (?since=&before=&limit=). |
GET /api/v1/drafts/:draft/proposals | Edit proposals to the active script (?status=). |
POST /api/v1/drafts/:draft/proposals | The native agent proposes a change to the active script (compile first): `{summary, edits |
POST /api/v1/drafts/:draft/proposals/:proposal/accept | The owner accepts a proposal. |
POST /api/v1/drafts/:draft/proposals/:proposal/reject | The owner rejects a proposal: {comment?}. |
POST /api/v1/backtests | Queue 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/:id | The result, once: waits up to waitMs (≤ 60 s), 202 while it runs (`?detail=summary |
POST /api/v1/trials/preview | What planned more runs do to the family’s trial count at the gate: {script, planned}. Free. |
POST /api/v1/evaluations/fast | Queue the advisory fast evaluation: {script} inline, or {draftId, script: {name}} (its summary attaches to the script). 202 {id}. Paid. |
GET /api/v1/evaluations/fast/:id | The fast evaluation, once: waits up to waitMs (≤ 60 s), 202 while it runs (`?detail=summary |
GET /api/v1/evaluations/full/availability | Whether full evaluation is available. |
POST /api/v1/evaluations/full | The gate: {script} or {draftId, script: {name}, reference?} (the verdict attaches to the script). 202 {jobId}. Paid. |
GET /api/v1/evaluations/full | The account’s full evaluations. |
GET /api/v1/evaluations/full/:id | One full evaluation: status, progress, verdict. |
GET /api/v1/markets | The venue’s markets (?venue=). |
GET /api/v1/candles | Recent candles: ?venue&market&from&to&interval (unix seconds; interval 60/300/900/3600/14400/86400). |
GET /api/v1/prices | The live price book: backtest, fast- and full-evaluation prices in credits. Public. |
GET /api/v1/me/credits | The account’s credit balance. |
GET /api/v1/me/credits/statement | The account’s credit statement. |
After it compiles
Section titled “After it compiles”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.
- 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.
- 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.