What perps mode is
Declaring strategy(..., instrument="perps") switches the broker emulator from spot accounting to isolated-margin perpetual futures. Four mechanics turn on, and each reports its own counters so a result is never quietly shaped by machinery you cannot see:
- Margin. Every entry commits isolated margin of
notional / leverage; entries that cannot be margined are rejected and counted. - Maker/taker fees. Fills are charged
makerFeePercentortakerFeePercentinstead ofcommissionPercent. See Slippage and Costs. - Funding. Recorded funding events settle against the open position (
funding="data", the default), or funding is off entirely (funding="off"). - Liquidation. Positions that exhaust their margin are force-closed at the broker's implicit liquidation stop, with
onLiquidationdeciding whether the run keeps trading or halts.
The default is instrument="spot", and a spot run carries none of this: the perps stats report zero (makerFeesPaid, takerFeesPaid, fundingPaid, fundingEventsApplied, fundingUnavailableCount, liquidationCount all 0, liquidationHalted: false) and the perps-only output series are omitted entirely.
The perps parameters and their exact validation rules live in the strategy() parameter table. The defaults, echoed verbatim by a minimal strategy(title=...) declaration: instrument="spot", leverage=1, maintenanceMarginPercent=0.5, makerFeePercent=0, takerFeePercent=0, funding="data", onLiquidation="continue".
Margin
Each perps entry commits isolated margin equal to the fill's notional divided by leverage. A probed 1-unit long filled at 109.54121974506596 with leverage=2 reports committedMarginSeries at exactly 54.77060987253298 while the position is open, and 0 when flat.
Any number literal > 0 is legal leverage, including fractions: leverage=0.5 runs cleanly and commits twice the notional. maintenanceMarginPercent=0 is also legal (the lower bound is inclusive).
Leverage scales margin, not PnL. The same signals with the same fixed quantity produce byte-identical netProfit (-90.70244890375582 across 7 trades) whether run as spot or as perps with leverage=0.5; re-run at leverage=5 with fees declared, the result differs only by the fees charged. Leverage decides how much equity a position ties up and where liquidation sits, not what a trade earns.
Margin rejection
An entry whose required margin exceeds available margin is rejected, counted in stats.rejectedOrders, and disclosed with a diagnostic. The required amount is fee-inclusive: in the probe (1000 units at a 109.54121974506596 fill, leverage=2, takerFeePercent=0.05, initialCapital=100) the rejection reports
STRATEGY_MARGIN_REJECTED: strategy.entry order 'Long' was rejected: required margin 54825.38048240551 exceeds available margin 100
which is exactly notional / leverage (54770.60987253298) plus the entry's taker fee (54.77060987253298). Every one of the run's 7 entries was rejected (rejectedOrders: 7, totalTrades: 0); rejections are counted, never thrown.
Liquidation
The broker maintains an implicit liquidation stop on every open perps position. For a probed long, the liquidation level is entry * (1 - 1/leverage) / (1 - maintenanceMarginPercent/100): at entry 109.54121974506596 with leverage=25 and maintenanceMarginPercent=0.5 that is 105.6880110103149, and the observed liquidated trade closes with pnl: -3.853208734751064, exactly that level minus entry. Liquidated trades carry exitReason: "liquidation", and in the probe they enter and liquidate on the same bar (entryBar: 10, exitBar: 10).
onLiquidation decides what happens next:
| Value | Observed behavior |
|---|---|
"continue" (default) | Trading continues. The probed run liquidates all 7 entries (liquidationCount: 7, 7 trades all with exitReason: "liquidation", liquidationHalted: false). |
"halt" | The first liquidation stops the strategy. The same script closes 1 trade (liquidationCount: 1, liquidationHalted: true) and every subsequent entry is rejected (rejectedOrders: 6). |
//@version=3
// Liquidation, onLiquidation="continue" (default): leverage=25 on the ~+/-10%
// oscillating fixture guarantees the broker's implicit liquidation stop is hit
// (long P_liq = E*(1-1/25)/(1-0.005) ~ 96.5% of entry; fixture lows dip ~10%).
// Expect trades closed with exitReason="liquidation", stats.liquidationCount
// counting them, liquidationHalted=false, and trading CONTINUING after each
// liquidation (multiple liquidated trades across the run).
strategy(title="Liquidation Continue", initialCapital=10000,
instrument="perps", leverage=25, maintenanceMarginPercent=0.5,
funding="off", onLiquidation="continue",
qtyType="fixed", qtyValue=1, pyramiding=1);
timeseries data = ohlcv(symbol=currentSymbol, exchange=currentExchange);
timeseries fastMa = sma(source=data.close, period=4);
timeseries slowMa = sma(source=data.close, period=9);
if (crossover(fastMa, slowMa)) {
strategy.entry("Long", "long");
}
plotLine(value=strategy.equity(), width=1, colors=["#2563eb"], label=["Equity"], desc=["Strategy equity"]);stats.bankruptcyDeficit reports the shortfall left when a liquidation fill lands beyond what the position's committed margin covers. It was 0 in every probed run: each observed liquidation loss (3.853208734751064) settled inside its committed margin (4.381648789802639 at leverage=25).
Funding
funding="data" (the default) settles recorded funding events against the open position; funding="off" disables funding entirely. Only those two literals are accepted (see validation).
//@version=3
// funding="data" WITH a host funding provider attached (probe harness
// --funding funding-provider.json: 8-hourly events, rate=0.0001, full
// coverage). A long perps position held to end of data pays positive-rate
// funding: stats.fundingPaid > 0 (positive = strategy paid),
// stats.fundingEventsApplied counts settlements applied to the open position,
// fundingUnavailableCount = 0 (full coverage), and the cumulative
// fundingPaidSeries ends at stats.fundingPaid.
strategy(title="Funding With Provider", initialCapital=10000,
instrument="perps", leverage=2, funding="data",
qtyType="fixed", qtyValue=1, pyramiding=1);
timeseries data = ohlcv(symbol=currentSymbol, exchange=currentExchange);
timeseries fastMa = sma(source=data.close, period=4);
timeseries slowMa = sma(source=data.close, period=9);
if (crossover(fastMa, slowMa)) {
strategy.entry("Long", "long");
}
plotLine(value=strategy.equity(), width=1, colors=["#2563eb"], label=["Equity"], desc=["Strategy equity"]);Observed with 8-hourly events at rate 0.0001 covering the whole window, and a long opened at bar 10 and held to the end:
- Settlements apply only while a position is open. The window carried 38 funding events;
fundingEventsApplied: 36(the events before the entry never touched the account). fundingPaidis signed from the strategy's side. The probed long under a positive rate paid out:fundingPaid: 0.36904136169411894, and the cumulativefundingPaidSeriesends at exactly that value.- Funding debits the position's committed margin. The identical run with
funding="off"ends withcommittedMarginSeriesat54.77060987253298; with funding applied it ends at54.401568510838864, lower by exactlyfundingPaid. - No data, no invented rate. The same script with no funding data available settles nothing (
fundingPaid: 0,fundingEventsApplied: 0) and instead counts every bar on which an open position had no covering funding data:fundingUnavailableCount: 289for the position held from bar 10 through the end of the 300-bar run. The count is the disclosure; the equity curve is not silently shaped by a guessed rate. funding="off"is an exact no-op. With the same funding data attached, all three funding stats stay0andfundingPaidSeriesstays all-zero.
Perps output series
Perps runs add two bar-aligned series to the strategy output, present on every perps run (including funding="off") and omitted on spot runs:
| Series | Meaning |
|---|---|
committedMarginSeries | Margin committed to the open position on each bar (54.77060987253298 while the probed 1-unit, leverage-2 long is open; 0 when flat). |
fundingPaidSeries | Cumulative funding paid, ending at stats.fundingPaid. |
Perps parameters on spot, and commission on perps
Declaring parameters for the instrument you did not pick is not an error, but the engine discloses or ignores them predictably. Every case below is probed:
| Declaration | Observed behavior |
|---|---|
Spot + makerFeePercent/takerFeePercent | Diagnostic STRATEGY_SPOT_MAKER_TAKER_IGNORED: strategy(): 'makerFeePercent' and 'takerFeePercent' apply only to instrument="perps"; spot uses 'commissionPercent'. Not charged: the run charged commissionPercent only (feesPaid: 1.442067423960801 at commissionPercent=0.1, makerFeesPaid: 0, takerFeesPaid: 0). With only one of the two declared, the message names just that one. |
Spot + onLiquidation="halt" | Same diagnostic code, different wording: STRATEGY_SPOT_MAKER_TAKER_IGNORED: strategy(): 'onLiquidation' applies only to instrument="perps". No liquidation machinery runs (liquidationCount: 0, liquidationHalted: false). |
Spot + funding="off" | Silently accepted, no diagnostic. |
Spot + leverage=5, maintenanceMarginPercent=2 | Silently accepted and echoed in the declaration, but inert: netProfit is byte-identical to the plain spot run (-90.70244890375582) and no liquidation machinery runs. |
Perps + commissionPercent=0.1 | Diagnostic STRATEGY_PERPS_COMMISSION_IGNORED: strategy(): 'commissionPercent' is ignored for instrument="perps"; fees come from 'makerFeePercent'/'takerFeePercent'. Not charged: feesPaid: 0 with maker/taker left at their 0 defaults. |
Validation
Perps parameters are validated at compile time, literal-only, with these exact rejections (each error also carries the declaration's source location):
| Declaration | Compile error |
|---|---|
instrument="margin" | strategy(): 'instrument' must be 'spot' or 'perps' |
instrument=1 | strategy(): 'instrument' must be a string literal |
leverage=0, leverage=-2, or leverage=someVar | strategy(): 'leverage' must be a number literal > 0 |
maintenanceMarginPercent=-1 or =100 | strategy(): 'maintenanceMarginPercent' must be a number literal >= 0 and < 100 (upper bound exclusive; 0 is legal) |
makerFeePercent=-0.1 | strategy(): 'makerFeePercent' must be a number literal >= 0 |
takerFeePercent=-0.1 | strategy(): 'takerFeePercent' must be a number literal >= 0 |
funding="on" | strategy(): 'funding' must be 'data' or 'off' |
onLiquidation="stop" | strategy(): 'onLiquidation' must be 'continue' or 'halt' |
Non-literal values can never sneak in structurally either: strategy() must be the script's first statement, so no variable exists to reference. Placing a var above it fails earlier with Script must start with a define(title, position, axis) or strategy(title) statement.