Paper trading on live runs

Execute a live run's signals in simulation — configure it, read its accounts, equity curve and KPIs.

Start a live run with a paper block and its hints are executed in simulation from its first tick, exactly as a backtest would execute them: orders fill, positions open and close, and the run accumulates equity and the same KPIs a backtest reports. Nothing is sent to an exchange. Without the block, the run has no paper trading.

MethodPathPurpose
POST/strategy/{strategyId}/liveStart a run — with a paper block to paper-trade it
GET/live/{runId}/paperEach paper account: equity, open positions, KPIs
GET/live/{runId}/paper/equityPage through the equity curve
GET/live/{runId}/signals?type=paperPaper items interleaved in the run’s signals (output: mix only)

Both paper routes follow the same access rule as a run’s signals: the run’s owner, or anyone if the run is public. A run started without a paper block answers 404 on both.

Starting a run with paper trading

POST /v1/strategy/6bsh31ikwkuivhtgcoa6s4/live
{
  "sources": [{"venueType": "cx", "exchange": "binance", "segment": "spot", "type": "ticker",
               "instruments": ["BTC/USDT", "ETH/USDT"]}],
  "paper": {"initialFunding": 1000, "feeRate": 0.001, "percentAmountToLock": 20}
}

201
{
  "strategyId": "6bsh31ikwkuivhtgcoa6s4",
  "runId": "6TzAPiPpsOWwBLdLBZCxwH",
  "stage": "SANDBOX",
  "state": "STARTING",
  …
  "paper": {"initialFunding": 1000.0, "buyFeeRate": 0.001, "sellFeeRate": 0.001, "feeLeg": "RECEIVED",
            "percentAmountToLock": 20.0, "output": "separate"}
}

The block takes the same economics as a backtest’s baseConfig, with the same defaults and limits, so one object moves between a backtest and a live run unchanged:

fielddefaultmeaning
initialFunding100Starting capital of each account, in that account’s quote currency. At most 1,000,000,000.
feeRate0.001Fee rate for both sides (0.001 = 0.1%).
buyFeeRate / sellFeeRatefeeRatePer-side rates; override feeRate.
feeLegRECEIVEDWhich asset fees are charged in: RECEIVED, QUOTE or BASE.
percentAmountToLocksee belowShare of the account’s free balance each entry locks, in percent (0–100].
outputseparateseparate, or mix to also write paper items into the run’s signals (see below).

The run’s own GET /strategy/{strategyId}/live returns the block as accepted: feeRate resolved into buyFeeRate/sellFeeRate, defaults filled in, feeLeg upper-cased.

Sizing. Without percentAmountToLock, each entry locks 10% of the account’s free balance, unless the strategy sets its own. A backtest defaults to all-in instead, but a live run trades several pairs at once, and all-in would let the first one hold the whole account. With 20, as above, the first entry locks 200 of the 1000, and a second entry opened while the first is still open locks 20% of the 800 left: 160.

What is refused

Anything invalid is a 400, never silently adjusted:

{"code": 400, "message": "paper.slippage is not a known field (known: [initialFunding, feeRate, buyFeeRate, sellFeeRate, feeLeg, percentAmountToLock, output])"}
{"code": 400, "message": "paper.initialFunding must be > 0"}
{"code": 400, "message": "paper.output must be one of [separate, mix]"}

Strategies that listen to their own execution. A strategy that overrides getExecutionCallback() reacts to its fills and stops, and paper trading is the only place a live run executes. Starting one without a paper block is refused ("paper": {} is enough):

{"code": 400, "message": "this strategy listens to execution events (it overrides getExecutionCallback()), and paper trading is where its orders are executed: a paper block is required, e.g. "paper": {}"}

Accounts: one per quote currency

Every quote currency the run trades gets its own simulated account, opened with initialFunding in its own currency the first time one of its pairs is traded. Accounts are never added together: there is no conversion between currencies.

instrumentsaccounts
["BTC/USDT", "ETH/USDT"]one: USDT (1000 USDT), shared by both pairs
["BTC/USDT", "ETH/BTC"]two: USDT (1000 USDT) and BTC (1000 BTC)

Pairs that share an account share its balance: sizing is always a share of what that account still has free.

Reading an account

GET /live/{runId}/paper returns each account as last recorded. For the run above, a little after 01:02 UTC (BTC/USDT bought at 01:00:10 and sold at 01:01:18 for −0.41; ETH/USDT bought at 01:00:25 and still open):

GET /v1/live/6TzAPiPpsOWwBLdLBZCxwH/paper

200
{
  "runId": "6TzAPiPpsOWwBLdLBZCxwH",
  "stage": "SANDBOX",
  "accounts": [{
    "currency": "USDT",
    "initialFunding": 1000.0,
    "equity": 1000.44,
    "equityAtMs": 1758330120000,
    "equityKind": "mark",
    "realisedPnl": -0.41,
    "trades": 1,
    "gaps": 0,
    "openPositions": [{"instrument": "ETH/USDT", "base": 0.05948, "cost": 160.0}],
    "kpi": {
      "totalTrades": 1, "winCount": 0, "lossCount": 1, "winRate": 0.0,
      "pnlTotal": -0.41, "pnlTotalPercent": -0.041,
      "sharpeRatio": null, "sortinoRatio": null, "cagr": -0.00041,
      "maxDrawdown": 0.41, "maxDrawdownPercent": 0.041
    }
  }]
}
fieldmeaning
equityThe latest recorded value: at the last closed trade (equityKind: equity, capital plus realised PnL), or at the last mark-to-market (equityKind: mark, which also values the open positions at market price). Until either exists, the starting capital.
realisedPnlSum of the PnL of the closed trades.
tradesClosed trades.
gapsTimes open positions were lost because the run was restarted with them open (see Gaps).
openPositionsWhat is held now: base in the base asset, cost in the account’s currency.
kpiThe same KPIs a backtest reports, over the trades closed so far; absent until the first one.

The KPIs use the backtest’s units: winRate and cagr are ratios (0.15 = 15%); pnlTotalPercent and maxDrawdownPercent are percentages (0–100 scale); sharpeRatio and sortinoRatio are per trade, not annualised, and null until there are enough trades to compute them.

The equity curve

GET /live/{runId}/paper/equity pages through an account’s whole curve, oldest first. It is kept for the life of the run, so unlike signals there is no moving window. The curve is per account, not per pair: BTC/USDT and ETH/USDT share one. Each point is one of:

kindwhenequity
equityafter every closed trade, of any paircapital plus realised PnL
markonce a minute of market time, while any position is opencapital plus realised PnL plus the open positions at market price
gapopen positions lost at a restartabsent

For the run above, from its start:

timeeventpoint
01:00:10BTC/USDT bought (200 USDT)—
01:00:25ETH/USDT bought (160 USDT)—
01:01:00a minute with both open, −0.38 unrealisedmark 999.62
01:01:18BTC/USDT sold, −0.41equity 999.59
01:02:00a minute with ETH/USDT open, +0.85 unrealisedmark 1000.44
01:02:31ETH/USDT sold, +0.72equity 1000.31
01:03:00nothing open—
01:03:40BTC/USDT bought again (200.06 USDT)—
01:04:00a minute with BTC/USDT open, −0.12 unrealisedmark 1000.19
GET /v1/live/6TzAPiPpsOWwBLdLBZCxwH/paper/equity?limit=3

200
{
  "points": [
    {"currency": "USDT", "kind": "mark",   "eventTsMs": 1758330060000, "equity": 999.62},
    {"currency": "USDT", "kind": "equity", "eventTsMs": 1758330078000, "equity": 999.59},
    {"currency": "USDT", "kind": "mark",   "eventTsMs": 1758330120000, "equity": 1000.44}
  ],
  "_links": {"next": {"href": "/v1/live/6TzAPiPpsOWwBLdLBZCxwH/paper/equity?cursor=eyJ0cyI6MTc1ODMzMDEyMDAwMCwiaWQiOiI…&limit=3"}}
}

Following _links.next:

200
{
  "points": [
    {"currency": "USDT", "kind": "equity", "eventTsMs": 1758330151000, "equity": 1000.31},
    {"currency": "USDT", "kind": "mark",   "eventTsMs": 1758330240000, "equity": 1000.19}
  ]
}

No _links.next: that was the last page. The cursor is opaque — use the href as given. sinceMs starts from a given market time instead of the beginning; limit defaults to 100 and caps at 1000.

Several accounts. Without currency, every account’s points come interleaved by time, each carrying its currency. For a run on BTC/USDT and ETH/BTC, ?currency=BTC narrows to the BTC account:

GET /v1/live/6TzAPiPpsOWwBLdLBZCxwH/paper/equity?currency=BTC

200
{
  "points": [
    {"currency": "BTC", "kind": "mark",   "eventTsMs": 1758330060000, "equity": 999.9981},
    {"currency": "BTC", "kind": "equity", "eventTsMs": 1758330097000, "equity": 1000.0012}
  ]
}

Output: separate or mix

With output: separate (the default), paper trading stays out of the run’s signals: read it with the routes above. With output: mix, each paper item is also written into the run’s own signals, as type: paper, right after the signal that caused it. Filter them with type:

GET /v1/live/6TzAPiPpsOWwBLdLBZCxwH/signals?type=paper&limit=4

200
{
  "signals": [
    {"type": "paper", "kind": "fill",   "eventTsMs": 1758330010000,
     "instrument": {"exchange": "binance", "segment": "spot", "symbol": "BTC/USDT"},
     "data": {"side": "buy", "orderKind": "market", "price": 84389.41, "amount": 0.00237,
              "counterAmount": 200.0, "feeBase": 0.00000237, "feeQuote": 0.0}, …},
    {"type": "paper", "kind": "trade",  "eventTsMs": 1758330078000,
     "instrument": {"exchange": "binance", "segment": "spot", "symbol": "BTC/USDT"},
     "data": {"side": "long", "entryTsMs": 1758330010000, "enterAmount": 200.0,
              "exitAmount": 199.59, "pnl": -0.41}, …},
    {"type": "paper", "kind": "equity", "eventTsMs": 1758330078000, "instrument": null,
     "data": {"currency": "USDT", "equity": 999.59}, …},
    {"type": "paper", "kind": "kpi",    "eventTsMs": 1758330078000, "instrument": null,
     "data": {"currency": "USDT", "totalTrades": 1, "winRate": 0.0, "pnlTotal": -0.41, …}, …}
  ],
  "availableSinceMs": 1757725212000,
  "_links": {"next": {"href": "/v1/live/6TzAPiPpsOWwBLdLBZCxwH/signals?cursor=…&limit=4&type=paper"}}
}

Each entry has the full signal shape described in Live execution (v, signalId, runId, stage, paramsVersion, emittedAtMs, order: null, regenerated, digest), shortened here to …. kind says what the item is:

kindwheninstrumentdata
fillan order filledthe pairside (buy/sell), orderKind (market, limit, stop, stopTrailing), price, amount, counterAmount, feeBase, feeQuote
tradea position closedthe pairside (long/short), entryTsMs, enterAmount, exitAmount, pnl
equityafter each closed tradenullcurrency, equity
kpiafter each closed tradenullcurrency and the KPIs of Reading an account
markonce a minute of market time while positions are opennullcurrency, equity, realisedPnl, unrealisedPnl, openPositions (how many)
gapopen positions lost at a restartthe paircurrency, base, cost

equity, kpi and mark are about a whole account, so their instrument is null and data.currency names the account. type combines with instrument: ?type=paper&instrument=ETH/USDT returns the fills, trades and gaps of that pair only.

Paper items are kept with the run’s signals, under the same moving window, but they are never pushed over the WebSocket channel, whatever relay says. For the complete, permanent record use the paper routes.

Gaps

A run can be restarted by the platform, for instance when a new version is deployed. Its paper trading carries on from its record: closed trades, realised PnL, equity curve and KPIs are all kept, and nothing is counted twice. Positions that were open at the restart, and any pending orders, cannot be carried over: each open position is reported as a gap, with what it held, and is no longer counted.

{"currency": "USDT", "kind": "gap", "eventTsMs": 1758333600000}

In the curve a gap has no equity, and the account’s gaps counts them. In a mix run the matching signal names the pair and what was held:

{"type": "paper", "kind": "gap", "instrument": {"exchange": "binance", "segment": "spot", "symbol": "ETH/USDT"},
 "data": {"currency": "USDT", "base": 0.05948, "cost": 160.0}, …}