PineconeX Web API
Documentation for the PineconeX HTTP API. PineconeX backtests and live-trades Pine Script® v6 strategies; this API lets you do everything the web app does — create and validate strategies, run backtests / parameter sweeps / robustness tests, and launch and monitor live trading bots — from your own scripts or an AI agent.
Getting an API key
Create a personal access token in the web app under Account → API keys. It looks like
pcx_live_… and is shown once. Send it as Authorization: Bearer <key> on every request.
export PINECONEX_API_URL=https://pineconex.com
export PINECONEX_API_KEY=pcx_live_your_key_here
curl -H "Authorization: Bearer $PINECONEX_API_KEY" "$PINECONEX_API_URL/api/v1/strategies"
Driving PineconeX from Claude or another AI agent? Install the companion skill — https://github.com/Pineconex/pineconex-web-skill — set
PINECONEX_API_KEYin its environment, and it follows the workflows below.
License
Documentation licensed under CC BY 4.0 — © 2026 PineconeX.
API reference
The supported public surface. Base URL https://pineconex.com (override with PINECONEX_API_URL).
All requests: Authorization: Bearer pcx_live_…. JSON bodies use Content-Type: application/json.
Versioning. All endpoints live under /api/v1. v1 stays stable; breaking changes ship as a
new major version (/api/v2, …) so existing integrations keep working.
Conventions: dates are YYYY-MM-DD; timestamps are ISO-8601 UTC; ids are UUIDs. Fields marked
optional may be omitted or sent as null.
Strategies
The Pine v6 code may use two PineconeX-exclusive namespaces (no TradingView equivalent):
ml.* (run an uploaded ONNX model in-strategy; Premium; //@runtime=2026.07.16-ml+) and
gex.* (dealer gamma exposure — gex.net/flip/pin/call_wall/put_wall/g1..g5;
//@runtime=2026.08.06-gex+). Live GEX is fetched from the options chain on Saxo (Eurex) today;
a backtest reads na (no historical options data), so GEX strategies are paper-traded live, not
backtested.
POST /api/v1/strategies — create
Request: { "code": "<pine v6 source>" } (required).
Response 201: StrategyResponse (below). name/description are parsed from the source;
status starts "unvalidated".
POST /api/v1/strategies/from-github — import from your connected GitHub repo
Request: { "stem": "<file stem>" }. Imports the tracked Pine file of that stem from your
connected GitHub repo as a strategy. Response 201: StrategyResponse.
GET /api/v1/strategies — list
Response: array of StrategyResponse.
StrategyResponse
id uuid
name string
description string
status "unvalidated" | "valid" | "invalid"
content_hash string
bytecode_hash string | null
code string | null (null for shared_protected strategies you don't own)
visibility "private" | "shared_open" | "shared_protected"
share_token uuid | null (owner only)
is_owner bool
created_at datetime
updated_at datetime
github_stem string | null
params_json5 string | null
GET /api/v1/strategies/{id} — get one · PUT /api/v1/strategies/{id} — update ({code}) · DELETE /api/v1/strategies/{id}
GET /api/v1/strategies/{id}/inputs
Response: array of input specs (the strategy's input.* variables), used to build parameter
overrides. Each has kind plus var_name, title, defval, swept, and kind-specific fields:
int/float:minval,maxval(optional),stepbool: (just defval)string:options: [string]
GET /api/v1/strategies/{id}/params → { "params_json5": string|null }
PUT /api/v1/strategies/{id}/params — body { "params_json5": "<json5>" } → 204
The params_json5 value is a JSON5 document (comments + trailing commas allowed) shaped as an
array of per-symbol entries, each with a configs array — every config runs a separate backtest
for that symbol + tf. Keys inside a config other than tf map to Pine input variable names
and override that input's default. A blank/whitespace-only body clears the file.
[
{
symbol: "AAPL",
configs: [
{ tf: "1D", sma_len: 20, use_filter: true }, // one backtest
{ tf: "60m", sma_len: 14 }, // another
],
},
{ symbol: "MSFT", configs: [ { tf: "1D", sma_len: 50 } ] },
]
It is a live/backtest deployment override only — sweep and significance jobs deliberately ignore it (they produce or permute configs themselves).
POST /api/v1/strategies/{id}/share — set visibility ({ "visibility": "private"|"shared_open"|"shared_protected" })
POST /api/v1/strategies/{id}/grant — share directly with another user
Request: { "email": "<user email>" }. Grants that user read access to this strategy (they see it
in their shared list). Owner only.
Validate
POST /api/v1/validate
Request: { "strategy_id": uuid }.
Response: { "status": "valid"|"invalid", "errors": [string], "warnings": [string] }.
Validation runs the same engine the job would. The effective runtime is resolved exactly as
dispatch resolves it — an explicit //@runtime=<version> pin if present, otherwise the
admin-promoted default — so a pinned strategy is checked by that engine rather than by whatever
the API host happens to carry. If an explicit pin cannot be honoured you get a warning saying
so (and the validation still runs on the fallback); an unpinned strategy never warns, because the
caller expressed no preference and there is nothing to act on. Rate-limited to ~20/min per user
(429).
ML models — Premium plan or higher
Upload an ONNX model and call it from Pine with the ml.* namespace. All three endpoints are
gated on Premium (403 on any lower plan, including pro and dedicated). Models are
private to your account.
GET /api/v1/models → array
id uuid
name string
version int (auto-incremented per (account, name))
sha256 string (hex digest of the .onnx bytes)
size_bytes int
input_dim int | null
output_dim int | null
created_at datetime
last_used_at datetime | null
POST /api/v1/models — upload
name string required (1..64 chars, must start alphanumeric, [A-Za-z0-9._-] only, no "..")
data_b64 string required (standard base64 of the raw .onnx file)
Max 20 MiB decoded (this route's body cap is raised to 32 MiB for the base64 overhead).
Response 201: { id, name, version, sha256, size_bytes, input_dim, output_dim }.
Uploading re-versions rather than overwrites: the same name gets version = max + 1.
The model is parsed and optimized at upload time (no inference is run), and rejected 400 if it
does not fit the calling convention: exactly one input tensor, input dtype f32/f64 with a
concrete last dimension, output dtype f32/f64/i64. Unsupported operator sets
(TreeEnsemble*, Scaler, ZipMap, …) are named in the error. Requires
//@runtime=2026.07.16-ml or later to actually run.
DELETE /api/v1/models/{id} → 204
Deletes the version (not the whole name lineage) and removes the file from every runner.
404 if the id is not yours.
Jobs
All launch endpoints return 201 with a JobResponse. Poll GET /api/v1/jobs/{id} for status.
JobResponse
id uuid
strategy_id uuid | null
job_type "backtest" | "sweep" | "robustness" | "stress" | "live"
status "pending" | "running" | "completed" | "failed" | "cancelled" | "timeout"
container_id string | null
config object (serialized JobConfig; varies by job_type)
auto_restart bool (live only; else false)
created_at datetime
finished_at datetime | null
progress_done int | null (sweep/robustness)
progress_total int | null
error_message string | null
data_split_adjusted bool | null (provenance of the bars this job ran on — see below)
data_split_adjusted is provenance, and null is not false. true = the bars were
split-back-adjusted, false = raw as the vendor served them, null = unknown: no dataset was
bound (live bots), the series came from outside the catalog, or the job predates the field. There
is one catalog row per (symbol, source, timeframe), so a re-fetch under a different adjustment
setting overwrites the Parquet in place — two runs of the same backtest can then legitimately
disagree, and this field is the only record of which series each one saw. When comparing results
across jobs, compare this first. Never render null as "raw".
Terminal: completed, failed, cancelled, timeout. Non-terminal: pending, running.
Fields common to backtest / sweep / robustness / stress
strategy_id uuid required
symbol string required (e.g. "AAPL")
timeframe string required ("1m","5m","15m","30m","60m","90m","1D","1W","1M"; case-sensitive: 1M=monthly, 1m=1min; 1H/1h alias→60m; other→400)
from_date, to_date date required (TRADE GATE, not a data slice — see below)
data_source string required ("yahoo","saxo","massive","ibkr","alpaca","bitstamp"
— see Data sources below; not every source carries
every symbol)
htf_timeframe string optional (higher timeframe for request.security)
htf_data_source string optional
intrabar_timeframe string optional (request.security_lower_tf)
intrabar_data_source string optional
intrabar_timeframe is also what makes use_bar_magnifier do anything. A strategy declaring
strategy(use_bar_magnifier = true) resolves a bar that touches both a resting stop and a
resting take-profit by walking the intrabar sub-bars and booking whichever leg price reached first
(a sub-bar touching both is itself ambiguous → the stop wins). Without an intrabar series the flag
is a silent no-op on backtest and sweep — the run succeeds, logs a warning, and keeps the
optimistic default of booking the take-profit. Robustness and stress need no series: their
permuted bars resolve the same tie deterministically from the bar's own OHLC (Brownian bridge), so
the p-value stays reproducible. Expect a magnified run to score worse — the bias is what was
removed.
from_date / to_date gate TRADES, they do not slice the data. The strategy is executed
over the whole stored series so indicators warm up correctly (a 200-bar EMA is already
converged on the first tradeable bar), but entries are only taken inside
[from_date, to_date], and an open position is force-closed at to_date
(exit reason DateRangeEnd). So widening the range changes which trades happen; it does not
change how the indicators are computed. This is the same contract for backtest, sweep,
robustness and stress.
POST /api/v1/jobs/backtest
Above fields, plus:
params_override object optional { var_name: number|bool } (strings rejected)
POST /api/v1/jobs/sweep
Above common fields, plus:
mode "grid"|"random"|"rbf" required
trials int optional (random/rbf; rbf calls it the budget)
metric string optional (default "net_pnl_pct". The objective the search hill-climbs.
One of: net_pnl_pct | return_over_dd | sharpe | profit_factor |
expectancy | win_rate | max_dd_pct (minimised) — or a custom
expression "expr: <formula>", see below.
ONLY rbf steers — grid and random ignore it, see below)
min_trades int optional (default 5; 0..1000. A trial with fewer closed trades can never
win — not in the search, not in the results ranking. 0 disables)
perm_seed int optional (sweep a bar-PERMUTED copy of the series instead of the
real bars — see below)
perm_block int optional (default 1; 1..1000. Permutation block size in bars.
Ignored unless perm_seed is set)
Which input.* vars are swept comes from the strategy (GET .../inputs, swept: true).
A 1-axis grid is valid — the cartesian product of a single axis is that axis.
400 if the strategy has no //@sweep parameters. There is no search space, so grid degenerates
to the single authored point and rbf exits non-zero in the container. Mark an input by putting
//@sweep on the line above its input.int / input.float call.
metric only aims rbf. Grid enumerates every cell and random samples blindly: both evaluate a
predetermined set of points no matter what the objective is, and you rank the output afterwards.
Only rbf hill-climbs, so only rbf has a search to aim. Sending metric with grid/random is not
an error — it simply has no effect.
Custom objective: "metric": "expr: <formula>". An arithmetic expression over the trial
metrics, e.g. "expr: net_pnl_pct - 0.5 * max_dd_pct + 0.1 * trades". Variables: net_pnl_pct,
max_dd_pct, trades, win_rate, profit_factor, expectancy, sharpe, return_over_dd
(aliases: pnl, dd/max_dd, n_trades, winrate, pf, romad). Operators: + - * / ( ) and
numeric literals. The search MAXIMISES the expression as written — a penalty term gets a minus
sign, and max_dd_pct is a positive percentage (a 12% drawdown is 12), so subtract it to punish
risk. A malformed expression is rejected 400 with the parser's error. The min_trades floor
applies to custom objectives too, and a division by zero disqualifies the trial rather than
winning by infinity.
min_trades is what makes the non-PnL objectives safe. Profit factor is gross profit / gross
loss, so a config that never trades has no losses and scores +inf; win rate has the same trap
(one lucky trade is 100%). Without a floor, optimising either converges on a strategy that refuses
to trade and reports it as a triumph. Measured on a real run (profit_factor, rbf, 60 trials):
min_trades=0 crowned a 6-trade config at PF 10.83 and spent 50/60 trials in the under-20-trade
region; min_trades=20 crowned a 23-trade config at PF 1.70 and spent 22/60. The floor changes
where the optimizer looks, not just what it reports. Only set 0 when optimising net_pnl_pct,
where a zero-trade config scores 0 and loses to anything profitable anyway.
Removed (2026-07-13): bayesian and monte_carlo, along with their restarts/steps/kappa
knobs. Both were benchmarked against a blind-random control at equal budget (246 evaluations): Monte
Carlo scored worse than random (23.87% vs 29.05%) and Bayesian tied it exactly. Neither earned the
"smarter search" it implied. Sending the old field names is harmless — they are ignored — but
mode: "bayesian" / "monte_carlo" now returns 400.
perm_seed — building a null distribution. With perm_seed set, the sweep runs against a
bar-permuted copy of the primary series. A permutation is fully determined by its seed, so
re-submitting the same sweep under N different seeds gives you N independent nulls of the
best-of-search score. No bars cross the wire — the runner already holds the Parquet and
shuffles it in memory.
That is an in-sample Monte Carlo permutation test, and it is the correction for data-mining bias: because the optimizer runs inside every permutation, the null becomes "the best score this strategy family can be fitted to noise", rather than "what one fixed rule scores on noise".
observed = sweep(...) # best-of-search on the real bars
null[i] = sweep(..., perm_seed=i) # best-of-search on permutation i
p = (#{null >= observed} + 1) / (N + 1)
Both sides must be best-of-search: comparing a single fixed-parameter run against a null of maxima drives the p-value toward 1 (wrong in the opposite direction).
Rejected with 400 if combined with intrabar_timeframe — sub-bar structure cannot be synthesized
from permuted bars, so intra-bar fills would price against a path that does not exist. The HTF, if
present, is re-derived by aggregating the permuted primary so the two series stay coherent.
POST /api/v1/jobs/robustness
Permutation (Monte Carlo significance) test: bar-permutes the price series N times, re-runs the strategy on each, and reports a p-value on whether the edge is real structure or luck. Above common fields, plus:
permutations int optional (default 200; 1..2000 — the null-distribution size)
metric string optional (default "net_pnl_pct". BOTH the statistic the p-value is
computed on AND the objective the search hill-climbs
inside every permutation — deliberately the same value,
see below. One of: net_pnl_pct | return_over_dd | sharpe |
profit_factor | expectancy | win_rate — or a custom
"expr: <formula>", same syntax as the sweep metric.
max_dd_pct is NOT accepted here — 400)
search_mode string optional ("fixed"(default)|"grid"|"random"|"rbf" — the SELECTION
PROCEDURE re-run inside every permutation. See below: this
is the null hypothesis itself, and the default is only
correct if the parameters were NOT found by a sweep)
search_trials int optional (candidates per permutation for random/rbf.
Ignored by fixed and grid)
min_trades int optional (default 5; 0..1000. A trial with fewer closed trades can
never be the winner the statistic is read from — applied
identically to the real run and to every permutation)
block_size int optional (default 1; 1..1000. 1 = single-bar permutation
(destroys all serial structure); >1 = block
permutation (shuffle N-bar chunks, preserving
structure shorter than N — a less strict null))
seed int optional (RNG seed; omit for a time-seeded run. The effective
seed is echoed back in the results for reproducibility)
400 if the strategy has no //@sweep parameters — for every search_mode, including fixed.
metric is the search objective too. An MCPT is only valid when the procedure re-run on the
permuted bars is the procedure that ran on the real bars. A search that optimised net PnL while
the test reported win rate would be a different procedure, and its null would not describe it — so
the search hill-climbs the statistic you ask for. Set it to whatever your sweep optimised. (Only
rbf steers; grid/random/fixed evaluate a predetermined set of trials either way, and the
statistic merely selects the maximum.)
max_dd_pct is rejected here on purpose: every accepted statistic is higher-is-better, which is
what makes the one-sided upper tail correct. A custom expr: statistic is accepted — the engine
maximises it as written, so making "high = good" true of the formula is the author's contract,
exactly as it is when choosing which built-in to test. If the sweep that found the parameters used
a custom expression, pass the same expression here (same-procedure rule).
min_trades is why a no-trade strategy cannot pass. With no floor, a strategy that takes zero
trades is a valid winner: its statistic is 0, every shuffled series also scores 0, and the test
returns p = 1.0000 — a confident-looking verdict about a strategy that never traded.
Results (GET .../results) include: p_value, observed_stat, the null_dist array
(+ mean/sd/percentiles), hurst + variance_ratio (structure of the price series,
strategy-independent), and the echoed permutations/block_size/metric/seed.
search_mode — what the null actually is. fixed (the default) re-runs the strategy's
authored input defaults on every permutation: one backtest each. Its null is "what this one rule
scores on noise", which is correct only if the parameters were chosen without looking at the
data.
If you swept the parameters and used the winner, fixed is optimistically biased — it cannot see
that N candidates were tried, and the maximum of N noise draws is large by construction. Declare
the search you actually used instead, and it is re-run inside every permutation, so the null
becomes "the best score this strategy family can be fitted to noise". Optimize with RBF, test
with RBF — and on the same metric.
Cost scales with the mode: fixed = 1 backtest per permutation, grid = the whole grid,
random/rbf = search_trials. permutations x search size is capped; over the limit
you get a 400 with the actual number. That price is intrinsic — it is proportional to the very
selection bias it removes.
Out-of-sample. There is no separate mode: hold data back with the date range. Sweep on the
training window, then run this test on the unseen window with the parameters fixed — fixed is the
correct null there, because no search ever touched those bars.
POST /api/v1/jobs/stress — Premium plan or higher (like robustness)
Synthetic-market stress test. Calibrates an Ornstein–Uhlenbeck + Poisson-jump process on the real series, then re-runs ONE fixed config across a grid of (mean-reversion half-life x jump intensity) cells, several simulated paths each, and reports where the strategy survives.
It is not an optimizer and not a significance test: it runs no search, and its market is synthetic. It answers "where does this config break?", not "is this edge real?".
Above common fields, plus:
model string optional ("ou_jump" — the only generator today)
half_lives [float] optional (X axis: reversion half-lives in bars. Max 12 values)
jump_rates [float] optional (Y axis: jumps per 1000 bars. Max 12 values)
paths int optional (default 20; 1..100 — simulated paths per cell)
jump_sigma float optional (0..50)
vol_mult float optional (0.1..10)
bars int optional (100..5000 — bars per synthetic path)
metric string optional (the statistic each cell is scored on)
seed int optional
params_override object optional { var_name: number|bool } (strings rejected)
The instrument's own calibrated coordinate is forced onto the grid, so a run is usually one row and one column larger than the axes you pass.
Pass params_override. Stress runs one fixed parameter set across every synthetic market, so it
should be the set you actually deploy — the same override you send to /jobs/backtest and
/jobs/live. Omit it and you are mapping the operating envelope of the .pine defaults, not of
the config you trade. (This is the opposite of sweep and robustness, which deliberately ignore it —
a sweep produces the config, and the permutation test must re-run the procedure you actually ran.)
Only the execution timeframe is used — no HTF, no intrabar. A strategy that depends on
request.security behaves differently here than in its backtest.
Results (GET .../results): calibration (phi, theta, half_life_bars, sigma,
jumps_per_1k, n_bars) and cells[], each with half_life_bars, jumps_per_1k, median,
p25, p75, mean, min, max, frac_profitable, median_trades.
POST /api/v1/jobs/live — available on every plan (free included)
Live trading is not plan-gated. What a tier buys is capacity — live bots draw on the same
concurrent-job quota as everything else (free 1, higher tiers more), so a 403 here means the
quota is full, not that the plan is too low.
strategy_id uuid required (must be validated — `status: "valid"` — or 400)
symbol string required (the primary symbol; with `symbols`, its first element)
symbols array optional (string[]; ≥2 entries = a multi-symbol BASKET traded by one
bot in one process — see below. 0/1 entries = ordinary
single-symbol bot)
timeframe string required (live subset: "5m","15m","30m","60m","90m","1D" — no
weekly/monthly/1m. "1H"→60m and "4H"→240m are aliased,
but 240m is not in the live subset, so 1D/90m/60m/30m/
15m/5m are the six that actually launch)
htf_timeframe string optional
intrabar_timeframe string optional
execute_orders bool optional (default true; false = signals + webhooks, no broker orders)
heartbeat_secs int optional
auto_restart bool optional (default false)
params_override object optional { var_name: number|bool }
broker string optional ("saxo"(default)|"alpaca"|"ibkr"|"lightspeed"|"bitstamp"
|"propfirm")
saxo_env string optional ("sim"|"live"; Saxo only)
webhook_url string optional (http/https; receives order/trade/fill events)
options_routing bool optional (default false; ALPACA ONLY — see below)
options_params object optional (per-bot options knobs; ignored unless options_routing)
broker is matched exactly — no trimming, no case-folding. "Saxo" is rejected
400 unknown broker 'Saxo' — supported: saxo, alpaca, lightspeed, ibkr, bitstamp, propfirm.
Multi-symbol baskets (symbols) are a paid-plan feature. Two or more entries launch a single
LiveMulti job: one container, one process, one broker account, one shared timeframe, counting as
one job against the concurrency quota. Constraints, all enforced server-side (the UI toggle is
cosmetic):
403on the free plan — pro / premium / admin only.400 "multi-symbol live is not supported for <broker>"onibkr(per-symbol client id),lightspeed(a separate bot binary) andpropfirm(a futures basket is a basket of contract months, each with its own front-month resolution — not exercised yet).- Every entry is a
tv_symbolthat must exist, be enabled, and belive_tradable; unknown or data-only rows are rejected400.
options_routing hands execution to the options runtime (Alpaca only). Each signal is scored
across the underlying shares and the option chain and the better risk-adjusted expression is
placed. It is refused 400 on any other broker ("no other broker's option order model has been
verified"), and it forces execute_orders = false — the runtime places every order, so a bot
that also traded would open the shares and the contracts for one signal. Under routing the
runner owns the webhook URL, so a client-supplied webhook_url does not reach the runtime.
options_params (every field optional; an unset field means "use the runtime default", never 0):
capital number cash the model may deploy per signal (clamped 1 … 10,000,000)
risk_frac number fraction of capital risked to the stop (clamped 0.0001 … 1)
min_dte int earliest expiry considered, in days (clamped 1 … 730 — never 0DTE)
max_dte int latest expiry considered, in days (clamped 1 … 730)
horizon_days number expected holding period, drives expiry (clamped 0.5 … 730)
allow_short bool open a short expression on a sell signal
dry_run bool score and log the decision, place nothing
auto_roll bool roll a held option to a later expiry before it decays (default ON)
Values are clamped, not rejected, and min_dte/max_dte are swapped if sent out of order. A
options_params blob on a non-routed job is silently dropped. Options routing suits fast
directional strategies; on a slow or mean-reverting one the premium decays while it waits for an
exit signal — say so before launching one for a user.
Errors specific to this endpoint: 429 if you exceed 30 launches/min; 400 "Concurrent job limit reached (N)…" when the plan's quota is full; 400 "<Broker> not connected: …" when the broker
has no stored credentials; 503 when every runner in the fleet is at capacity.
The broker is not a detail — it changes what protective orders exist. Pine is the same on every venue; the order model underneath is not, and the difference is not guessable:
| Broker | Instruments | Stop-loss / take-profit |
|---|---|---|
| Saxo | EU + US equities | Native OCO at the broker: a resting stop and a resting TP, linked (a fill on one cancels the other). saxo_env picks sim (paper) or live. |
| Alpaca (equity) | US equities | Native OCO, same as Saxo. Margin accounts can short. |
| Alpaca (crypto) | US-dollar pairs only (BTCUSD, ETHUSD, …; a symbol is tradeable here iff its alpaca_us_symbol is non-null) | Only one exit can rest, and it is the stop — crypto refuses oco/bracket, and the first resting exit reserves the whole coin balance. The take-profit is therefore bot-managed: evaluated at bar close, and on a hit the bot cancels the stop and market-closes. Fractional size; fees are taken in the coin. |
| Bitstamp (spot) | USD + EUR spot pairs (iff bitstamp_pair is non-null) | No native stop or TP exists at all. Spot accepts stop_price and answers 200 OK with an order id, but creates nothing. Every stop on a Bitstamp bot is synthetic — checked by the bot at bar close, on a 24/7 market. Long-only (no shorting on spot). |
| Lightspeed | US equities | Market orders only — nothing rests, so nothing protects. |
| IBKR | US equities | Market orders only. |
| PropFirm | CME futures, through the firm's gateway (Tradovate) | Live trading only — a prop-firm gateway is an execution rail, its market data is entitled per account and non-redistributable, so there is no backtest path and no catalog entry. The bot trades the front month resolved at launch and does not roll — stop and relaunch before expiry. The firm's daily loss limit, trailing drawdown and flat-by time are enforced on its side and are invisible to the bot: a breach flattens every position and locks the account, which is a way for a position to vanish with none of the bot's orders filling. |
Bitstamp has no env field on the launch request — the environment (sandbox = the venue's
only paper mode, or live = real money) is fixed when the credentials are saved. Check it with
GET /api/v1/bitstamp/status before launching.
A Bitstamp bot also refuses to adopt coins it cannot price. A spot balance is not a position and Bitstamp stores no average entry price, so the bot reconstructs a cost basis from your fill history — and a coin that was deposited (or bought outside the API's 30-day transaction window) has no purchase price anywhere. Rather than invent one, the bot logs that it cannot price the holding and stays flat. Fund a Bitstamp bot's account by buying the coin, not depositing it.
GET /api/v1/jobs — list (recent) → array of JobResponse
Your own jobs, newest first, hard-capped at 50. There are no query parameters — no
limit/offset, no status or type filter, no pagination cursor. Filter client-side.
GET /api/v1/jobs/{id} — one JobResponse (status synced from runner if still running)
GET /api/v1/jobs/{id}/results — metrics JSON (shape varies by job_type)
- backtest — performance metrics +
hurst/variance_ratio - sweep —
mode,param_names,total_trials, andtrials[]. Each trial:params(by input title),net_pnl_pct,max_dd_pct,n_trades,win_rate,profit_factor,sharpe. Rank them yourself — the server does not pick a winner.sharpeis a per-trade Sharpe (mean / stdev of trade PnL), not annualised and with no risk-free rate: read it as consistency of edge per trade. It is absent from results written before 2026-07-13. - robustness —
p_value,observed_stat,null_dist[](+ mean/sd/percentiles),hurst/variance_ratio, and the echoedpermutations/block_size/metric/seed - stress —
calibration+cells[](see the stress endpoint above)
When ranking sweep trials yourself, apply the same min_trades floor the search ran under (it is
echoed in the job's config.sweep_config.min_trades) — otherwise you can crown a trial the
optimizer had already disqualified.
GET /api/v1/jobs/{id}/logs — Server-Sent Events stream. Authenticate with the normal
Authorization: Bearer pcx_live_… header and read the stream (e.g. curl -N). API keys are not
accepted as a ?token= query parameter (that path is reserved for the web UI's short-lived session
token, since browser EventSource can't set headers).
DELETE /api/v1/jobs/{id} — stop/cancel (live: soft-cancel kept in history; batch: hard delete)
Stopping a live bot cancels its resting orders; it does not close the position. On shutdown the bot cancels the entry and exit orders it left at the broker (an unmanaged resting stop is a hazard: a stop-loss is a sell, and a sell with no position behind it can open a short). Whatever is held is left alone and named in the log as unprotected — closing it is the user's call, in their broker. Relaunching re-adopts and re-protects the position. Never tell a user that stopping the bot flattened them.
POST /api/v1/jobs/{id}/analyse — AI (descriptive) analysis of results
Request: { "provider": "gemini"|"mistral"|null } (null = the server default; see
GET /api/ai/providers). The job must be completed.
Response: { "analysis": "<markdown>", "model": "<model id>" }.
POST /api/v1/jobs/compare/analyse — AI (descriptive) analysis comparing several jobs
Request: { "ids": [uuid, …], "provider": string|null } — 2 to 6 ids, each completed and
yours (400 "Expected 2–6 job IDs" otherwise). Same response shape as /analyse.
POST /api/v1/jobs/patch-preview — preview a strategy's inputs after overrides, without running
Request: { "strategy_id": uuid, "params_override": {…}|null }. Returns
{ "code": "<pine source with the defaults rewritten>" } — the cheap way to check a
params_override resolves before launching a job with it.
Data
Data sources
A source can only serve a symbol it has a ticker for — the per-symbol tickers on
GET /api/v1/data/symbols are the authority, and a source whose ticker is null returns 400.
data_source | Coverage | Notes |
|---|---|---|
yahoo | Equities + crypto | Default, no account needed. Refuses any intraday range older than 730 days. |
saxo | EU + US equities | Needs a connected Saxo account. saxo_uic must be non-null (crypto has none). |
alpaca | US equities + US-dollar crypto pairs | Needs a connected Alpaca account. Crypto history starts 2021-01-01. |
massive | US equities (a 10-year window on our plan) | Also the source behind Pine's request.financial fundamentals and the corporate-action series; Yahoo is merged in as a second fundamentals source per series. Server-side rate-limited, so a long intraday range is paced across many paged calls — a fetch can take minutes without anything being wrong. Vendor rejections are surfaced verbatim rather than as a generic failure. |
ibkr | Equities | Needs IBKR configured (TWS/Gateway). |
bitstamp | Crypto (USD + EUR pairs) + a few FX pairs | No account or key needed — public endpoint. Timeframes 1m 5m 15m 30m 60m 1D only. |
Use bitstamp for deep intraday crypto history. It is the only source that reaches it:
Yahoo cuts intraday off at 730 days and Alpaca's crypto data begins in 2021, while Bitstamp's
public series goes back to 2011 and quotes real BTC/USD (not USDT). A multi-year hourly
Bitcoin backtest — the window the MCPT literature uses — is only reproducible from this source.
GET /api/v1/data/symbols → array
id, tv_symbol, tv_full_symbol, display_name, index_name,
yahoo_ticker|null, massive_ticker|null, ibkr_symbol|null, alpaca_us_symbol|null,
saxo_uic|null, bitstamp_pair|null,
live_tradable bool,
strategy_profile string|null
live_tradable = false means data-only. A cash index (DAX 40, CAC 40, …) has a price series
but is not an instrument anyone can hold, so it backtests and sweeps normally and is refused at
live launch: 400 "<sym> is a data-only symbol — an index level, not an instrument you can hold. … Trade a tracking ETF instead (e.g. DAXEX or DBXD for the DAX, CACC for the CAC 40)." Check the
flag before offering a symbol for a live bot; the same rejection applies to every entry of a
symbols basket. (This is deliberate: those rows are mapped at the broker's index CFD for their
bars, so treating them as tradable would have opened a leveraged CFD position from a strategy
backtested on the unleveraged cash index.)
Crypto lives under index_name "Crypto (USD)" / "Crypto (EUR)"; tv_symbol is the
pairless form (BTCUSD, ETHEUR). The per-broker tickers differ and are not
interchangeable — BTC-USD (Yahoo) vs BTC/USD (Alpaca) vs btcusd (Bitstamp) — but you
never send those: address everything by tv_symbol and the API maps it per source and per
broker. A symbol whose alpaca_us_symbol is null cannot be traded on Alpaca (most EUR pairs),
and one whose bitstamp_pair is null cannot be traded on Bitstamp.
GET /api/v1/data/catalog → array of cached OHLCV datasets
id, symbol_id, tv_symbol, display_name, index_name,
source, timeframe, from_date, to_date, row_count, updated_at, expires_at|null
GET /api/v1/data/saxo-catalog → array of cached Saxo datasets
The Saxo-sourced subset of the catalog, for the connected Saxo account. Same entry shape as
/data/catalog. Empty if no Saxo account is connected.
POST /api/v1/data/fetch — download/cache OHLCV
symbol_id uuid required
timeframe string required
from_date date required
to_date date required
source string optional (default "yahoo")
Response: the resulting catalog entry.
GET /api/v1/data/massive-status → { "configured": bool }
Whether the Massive data source is configured on the server (i.e. whether source: "massive"
fetches will work). No account of your own is required.
The catalog is a shared, platform-wide cache, not per-account storage. POST /data/fetch adds
to it and any account may then use the cached series. Conversely there is no user-facing way to
delete or edit a cached dataset — eviction is the retention watchdog's job, and the repair/delete
endpoints are operator-only. A data_source a symbol has no ticker for returns 400, and old
intraday windows may simply not exist at the source (Yahoo cuts off at 730 days).
Brokers
Every broker exposes the same three shapes: a status read, a connect write, and a
disconnect. Status is always safe to poll; the disconnect is idempotent (204 even if
nothing was stored). Credentials are encrypted at rest and are stripped out of any job config
that is persisted.
| Broker | Connect over the API? | How |
|---|---|---|
| Bitstamp | yes | POST /bitstamp/credentials — API key + secret |
| Alpaca | yes | POST /alpaca/keys — key id + secret (the OAuth flow is browser-only) |
| Lightspeed | yes | POST /lightspeed/credentials |
| IBKR | yes | POST /ibkr/settings — host/port of your own TWS or Gateway |
| Prop firm | yes | POST /propfirm/credentials — firm id + login + the app id/cid/secret the firm issued |
| Saxo | no | OAuth + PKCE redirect; must be completed in the web UI |
Saxo — GET /api/v1/saxo/status
connected bool
expired bool (access token past expires_at; false when not connected)
expires_at datetime|null
env string ("sim"|"live"; falls back to the server default when
not connected — this field is never null)
margin_trading_allowed bool|null (Saxo IsMarginTradingAllowed, captured at connect)
POST /api/v1/saxo/connect (body { "env": "sim"|"live" }, optional) returns
{ "url": … } — an authorization URL you must open in a browser; the PKCE verifier is held
server-side against the state, so the redirect cannot be completed from a script.
DELETE /api/v1/saxo/disconnect → 204.
Alpaca — GET /api/v1/alpaca/status
connected bool
mode string|null ("oauth"|"apikey")
region string|null ("us"|"eu")
env string|null ("paper"|"live")
account_id string|null (Alpaca account_number)
scope string|null (OAuth scopes; null in apikey mode)
multiplier int|null (1 = cash, 2 = margin, 4 = day-trading margin)
shorting_enabled bool|null
POST /api/v1/alpaca/keys → 204 — connect with an API key pair
key_id string required
secret string required
region string optional ("us" default; "eu" is rejected 400 — not supported yet)
env string optional ("paper" default | "live" = REAL MONEY)
The pair is verified against Alpaca's /v2/account before it is stored, and multiplier +
shorting_enabled are captured from that same response — which is how the bot knows whether a
short entry is even possible on your account. Saving keys clears any OAuth token, and vice versa:
an account is in exactly one mode.
POST /api/v1/alpaca/connect (body { "region": "us" }, optional) returns an OAuth
{ "url": … } for the browser flow. DELETE /api/v1/alpaca/disconnect → 204.
POST /api/v1/bitstamp/credentials → 204
api_key string required
api_secret string required
env string required ("sandbox" = demo funds, the venue's only paper mode
| "live" = REAL MONEY)
Both halves are verified against Bitstamp before they are stored, and both are secret — on Bitstamp the key is the identity, unlike Alpaca where the key id is public. The key must carry the "View your transactions" permission: Bitstamp's order status has no fill price, so transactions are the only place a bot can learn what it paid. A key without it is rejected at connect rather than mid-trade.
GET /api/v1/bitstamp/status → { connected, env, account }, where account lists the funded
currencies (Bitstamp has no account number). DELETE /api/v1/bitstamp/disconnect → 204.
POST /api/v1/lightspeed/credentials → 204
api_key string required
account string required
env string required ("cert" | "production")
ws_url string required
Stored as given — unlike Alpaca and Bitstamp there is no verification round-trip, so a bad
credential surfaces when a bot launches, not here.
GET /api/v1/lightspeed/status → { connected, account, env, ws_url }.
DELETE /api/v1/lightspeed/disconnect → 204.
POST /api/v1/ibkr/settings → 204
host string required (your TWS / IB Gateway host)
port int required (non-zero)
client_id int required (base client id)
IBKR is not a hosted credential — you point PineconeX at your own running TWS/Gateway. The
host is SSRF-guarded: loopback, RFC1918 and link-local addresses are refused
(400 "host is not a permitted address"). client_id is the base reserved for data fetches;
each live bot is assigned base+1 upward, skipping ids already in use by your running jobs.
GET /api/v1/ibkr/status → { connected, host, port, client_id }.
DELETE /api/v1/ibkr/disconnect → 204.
Prop firm — GET /api/v1/propfirm/firms → array
id uuid
name string (the firm, e.g. "Apex")
gateway string ("tradovate")
env string ("demo"|"live")
The firm is data, the gateway is code: onboarding a new firm is a row, not a release. Pick an
id from this list for the connect call.
POST /api/v1/propfirm/credentials → 204
firm_id uuid required (from /propfirm/firms)
username string required (your gateway login)
password string required
app_id string required \
cid string required > issued BY THE FIRM together with the account — we mint nothing
sec string required /
Verified against the venue before anything is stored, so a bad credential fails here rather than
at bot launch. Two failure modes that do not look like failures are handled for you: a rejected
login answers HTTP 200 with an errorText body, and too many attempts answers 200 with a
rate-limit penalty rather than a token (that is a lockout, not a wrong password). The account must
have API market data enabled — its absence is failed at connect, because otherwise no bars can
be fetched and the bot would die at warmup days later.
GET /api/v1/propfirm/status → { connected, firm, gateway, env, account }.
DELETE /api/v1/propfirm/disconnect → 204.
Live trading only. There is no propfirm data source: gateway market data is entitled per
account and non-redistributable, so it is never cached into the shared catalog. Backtest a futures
strategy from another source, then trade it here.
IBKR Web API (OAuth) is a stub pending IBKR onboarding. GET /api/v1/ibkr/web/status always
returns { "connected": false, "account_id": null } and POST /api/v1/ibkr/web/connect always
returns 400 "IBKR Web API integration is pending onboarding approval." Do not build against it.
Account & keys
GET /api/v1/auth/me → profile
id uuid
email string
plan "free" | "pro" | "premium" | "dedicated" | "admin"
name string | null
phone string | null
telegram_handle string | null
telegram_bot_token string | null (returned decrypted — it is your own credential)
telegram_chat_id string | null
github_id int | null
github_linked_repo string | null ("owner/repo")
created_at datetime
dedicated_vps object | null ({ subdomain, status }; Dedicated tier only)
PATCH /api/v1/auth/me — update profile fields → the same profile object
All fields optional: email, phone, telegram_handle, telegram_bot_token,
telegram_chat_id, github_linked_repo.
Two different update semantics, which is easy to get wrong. telegram_*,
github_linked_repo and email are coalesced — omitting them (or sending "") leaves the
stored value alone, so they cannot be cleared here. phone is written unconditionally:
omitting it erases your stored phone number. Send it back on every PATCH unless you mean to
clear it. A non-empty phone must start with + and hold 7–15 digits.
Changing github_linked_repo also moves the sync webhook: it is deleted from the old repo and
registered on the new one.
DELETE /api/v1/auth/me → 204 — GDPR Art. 17 erasure. Irreversible.
Stops and deletes every running job, hard-deletes your strategies, bot events and parse errors (cascading to jobs, results, shares and all stored broker credentials), anonymises the account row, and invalidates the session. Billing columns are retained for tax records. There is no undo and no confirmation step — the request itself is the confirmation.
POST /api/v1/auth/telegram/test → 204
No body. Sends a canned message to the Telegram bot token + chat id stored on your profile, so
you can prove the wiring before a bot depends on it. 400 names the missing half
("Telegram bot token not configured" / "Telegram chat ID not configured") or relays
Telegram's own error.
GET /api/v1/newsletter/me → { "subscribed": bool } · PUT /api/v1/newsletter/me — body { "subscribed": bool } → { "subscribed": bool }
Opt your account in or out of the product-updates newsletter (keyed by your account email).
GET /api/v1/auth/keys → list your keys (metadata only; never the secret)
POST /api/v1/auth/keys — mint a key. Session-JWT only (an API key cannot mint keys → 403).
Request { "name": string }. Response { id, name, key_prefix, key } — key shown once.
DELETE /api/v1/auth/keys/{id} — revoke → 204
GitHub
Link a GitHub repo and import Pine files from it (see
POST /api/v1/strategies/from-github). Linking is browser-only — the OAuth redirect cannot be
completed from a script — but once linked, the read endpoints work with an API key. All of them
return 400 "No GitHub account linked" until the browser flow has run once.
GET /api/v1/auth/github → { "url": … }
The authorization URL to open in a browser (scopes read:user user:email repo write:repo_hook).
GET /api/v1/auth/github/callback and .../exchange are steps of that redirect dance and are not
useful to call directly.
GET /api/v1/auth/github/repos → [{ "full_name": "owner/repo" }]
Note: private repos only — public ones are deliberately filtered out.
Set the chosen one via PATCH /api/v1/auth/me { "github_linked_repo": "owner/repo" }.
GET /api/v1/auth/github/files → array
The linked repo's tree, grouped by file stem — the unit from-github imports.
stem string (path without extension, e.g. "strategies/mrpivot")
pine bool (a <stem>.pine exists)
json5 bool (a <stem>.json5 exists — the params override, see /strategies/{id}/params)
md bool (a STRATEGY.md exists in that stem's directory)
GET /api/v1/auth/github/file?path={repo-relative path} → { "content": "<file text>" }
Path traversal (.., .) is rejected 400 "invalid path".
POST /api/v1/auth/github/sync-webhook → 204
No body. Re-registers the push webhook on the linked repo — the repair for a webhook that was
deleted on the GitHub side. 204 means "request sent"; it does not prove GitHub accepted it.
Dedicated VPS & SSO
The Dedicated VPS tier gives a customer a single-tenant, isolated PineconeX instance at
<subdomain>.pineconex.com (their own API + runner + database). The endpoints below run that
tier. They are outside the /api/v1 API-key surface — they use browser session auth or
admin auth, so an API key (pcx_live_…) cannot call them. Documented for completeness.
GET /api/dedicated/sso — mint an SSO handoff link (session auth)
Called on pineconex.com by a signed-in user who owns an active Dedicated instance. Returns a
short-lived login link to their box:
{ "url": "https://<subdomain>.pineconex.com/api/auth/sso?token=<jwt>" }.
The token is HS256, signed with that instance's per-instance secret, 60-second TTL, bound to
the subdomain. 400 if the account has no active Dedicated VPS. (This is what the "Go to my VPS"
button calls.)
GET /api/auth/sso?token={jwt} — Dedicated-instance login (no auth header)
The only login path on a Dedicated instance (INSTANCE_MODE=dedicated) — there is no OAuth
and no login page. Verifies the handoff token against the instance's SSO_SECRET, checks the
email against the instance allowlist, admits it as a box admin, sets the refresh cookie, and
302s into /app/strategies. 401 on a bad/expired token, a non-allowlisted email, or a
non-dedicated instance. Security rests on the per-instance HMAC secret + TTL, not on the URL.
On a Dedicated instance the admitted owner is a box admin, which unlocks the operator surface
under /api/admin/*. That surface is documented separately — see the
vps branch of the docs repo.
Limits & errors
-
Auth is header-only:
Authorization: Bearer pcx_live_…. Keys are never accepted in the URL (query string) — including for the SSE logs stream. -
Rate limits (per user,
429): validation ≈ 20/min, job launches ≈ 30/min. On429, back off and retry later — don't loop. -
Plan quotas are enforced at launch/create time and come back as
400with a message naming the limit (not403), e.g."Concurrent job limit reached (1). Wait for a running job to finish."Plan Concurrent jobs Strategies ML models free1 5 0 pro5 unlimited 0 premium10 unlimited unlimited (The numbers are server-configurable defaults, so treat the error message as authoritative.)
-
Plan-gated features (
403 forbidden, no message): robustness (POST /jobs/robustness), stress (POST /jobs/stress) and the ML model endpoints (/models/*) require Premium. The two job types are also the most expensive on the platform (a permutation test is N full backtests), so the gate doubles as a cost control. Backtest, sweep and live trading are open to every plan, free included — a rejection on/jobs/liveis the concurrent-job quota, not the tier. -
A disabled or deleted account is
401, not403— the plan is re-read from the database on every single request, so revoking access takes effect immediately, mid-session and mid-key. -
Sizes: request bodies are capped at 1 MiB (
413); strategy source at 256 KiB (400). -
Symbols are plain tickers (e.g.
AAPL) — no/,\, or..; invalid symbols return400. -
Errors are
{ "error": string }with the HTTP status;401= missing/invalid/expired key.