Skip to main content

QuantScript v2 reference

The complete QuantScript v2 API: connections and markets, tickers and candles, balances, positions and leverage, orders, all ta indicators, Decimal and datetime, state, logging, notifications, language rules and limits.

QuantScript version: 2.0 applies to strategies created after QuantScript v2 became the default. If your strategy was written with Market.Ticker, Orders.Buy or TA.RSI, it is a v1 strategy. See Moving from QuantScript v1 to v2.

QuantScript is the language QuantPilot strategies are written in. It is a dialect of Starlark (a small, safe subset of Python) with a trading API built in: market data, orders, balances, positions, technical indicators and persistent storage. The same script is used for backtests, paper trading and live trading.

This article is the reference. It covers the complete API surface as it exists today, one entry per type, method, attribute and limit. Every code sample in it has been checked against the QuantScript v2 validator.

Sample code assumes conn, spot and perp were opened at the top level of the script:

conn = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid")
spot = conn.get_market(Market.SPOT)
perp = conn.get_market(Market.LINEAR_PERPETUAL)

How a strategy runs

Two phases: set-up, then Run() every tick

A strategy has two phases.

Set-up (runs once). When the strategy starts, the engine executes the top level of the script from top to bottom. Use it to declare your parameters, open your connection and markets, and define helper functions.

Tick (runs many times). The engine then calls your Run() function once per tick. Live, a tick is roughly once per second. In a backtest, simulated time advances one minute at a time and Run() is called at every boundary of the smallest timeframe your strategy reads: get_candles(..., timeframe="1h", limit=200) counts as one hour, and get_ticker counts as one minute. So a strategy that only reads 1-hour candles runs once per simulated hour, while one that also reads a ticker runs every simulated minute. Orders are matched against every 1-minute bar either way.

Everything that needs to happen repeatedly (reading prices, computing indicators, placing orders) goes inside Run() or a function it calls.

# --- Set-up: runs once ---
StrategyParams = {
"symbol": "BTC/USDC",
"timeframe": "1h",
}

conn = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid")
spot = conn.get_market(Market.SPOT)

# --- Tick: runs every tick ---
def Run():
ticker = spot.get_ticker(StrategyParams["symbol"])
log.info("last price: " + ticker.last.string())

Run() must exist and must take no parameters. A script without it, or with def Run(x):, is rejected before it starts.

If your logic should fire once per closed candle (a crossover check, for example), remember the timestamp of the newest candle and return early while it has not changed:

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)
if candles[0].timestamp == state.get("last_bar", 0):
return # same 1-hour bar as last tick
state["last_bar"] = candles[0].timestamp
# ... once-per-bar logic below ...

What is allowed at the top level

Only two kinds of statements may appear at the top level of the script: assignments and function definitions. Control flow (if, for) and bare function calls are rejected by the validator.

# Allowed at the top level
SYMBOL = "BTC/USDC:USDC"
conn = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid") # a call on the right of "=" is fine
perp = conn.get_market(Market.LINEAR_PERPETUAL)

def bars():
return perp.get_candles(SYMBOL, timeframe="1h", limit=200)
# Rejected at the top level
conn = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid")
spot = conn.get_market(Market.SPOT)

if True: # control flow is not allowed here
SYMBOL = "ETH/USDC"

def Run():
pass

Anything that involves logic or a side effect belongs inside a function.

Variables assigned at the top level keep their value from one tick to the next, until the strategy restarts. Variables created inside Run() are gone when the tick ends. For values that must survive a restart, use state (below).

StrategyParams: the settings a user can change

Put every tunable value (symbol, timeframe, thresholds, sizing, lookbacks) in a single top-level dictionary named StrategyParams. QuantPilot reads this dictionary and shows one form field per key, so a strategy can be tuned in the app without editing code.

StrategyParams = {
"symbol": "BTC/USDC:USDC",
"timeframe": "1h",
"leverage_value": 5,
"capital_allocation_pct": 0.10,
"rsi_length": 14,
"rsi_oversold": 30,
}

Rules:

  • Values are plain literals: numbers, strings, booleans. Convert to Decimal when you use them for money or price math: Decimal(str(StrategyParams["rsi_oversold"])).

  • Read StrategyParams inside Run(); never write to it. When a user changes a parameter, QuantPilot rewrites the dictionary in the source and saves a new version.

  • StrategyParams holds inputs the strategy reads. Values the strategy writes each tick (counters, entry prices, order ids) belong in state.

  • Do not put your total capital in StrategyParams. The engine wallet holds it, and your strategy reads it with get_balance() or get_account(). Express sizing as a fraction or a fixed order size instead.

state: values that survive restarts

state is a dictionary-like global that persists between ticks and across restarts. Use it for runtime bookkeeping: whether you are in a trade, the entry price, an order id to check next tick, a cooldown timestamp.

run_count = state.get("run_count", 0)
state["run_count"] = run_count + 1

if "entry_price" in state:
entry = state["entry_price"]
log.info("in a trade since " + entry.string())

state.pop("pending_order_id", None) # delete a key; no error if it is absent

Operation

What it does

state["k"] = v

Store a value. Storing None is allowed and keeps the key.

state["k"]

Read. Raises if the key is absent. On the first tick nothing is set yet, so prefer state.get.

state.get("k", default)

Read with a fallback. default is None if omitted.

state.pop("k", default)

Remove the key and return its value. Without a default, raises if absent.

"k" in state

Membership test.

len(state), for k in state:

Count and iterate keys. Iteration order is unspecified.

state.items()

List of (key, value) pairs.

state.clear()

Remove everything.

There is no del state["k"]. Starlark has no del. Use state.pop.

Only scalar values can be stored: None, bool, int, float, str, Decimal, datetime and timedelta. Lists, dicts, sets and tuples are rejected on assignment. To keep several ids, join them into one string and split it on read, or re-derive them from market.get_open_orders(...).

Limits: 1,000 keys and 256,000 bytes in total; exceeding either raises when you assign. State is saved after every tick that changes it and kept for 7 days after the strategy stops. Re-enabling within that window restores it.

Do not seed state at the top level. The top level runs again on every restart and would overwrite the values you wanted to keep. Read with state.get(key, default) inside Run() instead.


Connections and markets

The API has two levels. A connection is one exchange account (or one exchange without an account). A market is one instrument on that connection: spot, or a linear perpetual. Prices, candles, orders, balances and positions all hang off the market.

Connection                                one per exchange (API key)
└─ get_market(instrument) → Market one per instrument (SPOT, LINEAR_PERPETUAL)
├─ get_ticker / get_candles / get_symbol_spec market data
├─ get_balance / get_account / get_position money state
├─ set_leverage
└─ place_order / cancel_order / get_order / ... orders

Open the connection and the markets you need once at the top level, then reuse them every tick.

Connection.get(exchange, id=...)

Opens an exchange, with or without an account on it.

Parameter

Type

Required

Notes

exchange

constant

yes

Connection.HYPERLIQUID, Connection.BINANCE, Connection.BYBIT or Connection.COINBASE.

id

string

no

Your connection id. Leave it out to get market data only.

Returns: Connection

trade = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid")   # account-backed
feed = Connection.get(Connection.BINANCE) # market data only

Whether you pass an id decides what the connection can do:

You pass

In a backtest or paper trading

Live

an id

that exchange, with a simulated wallet

your account on that exchange

no id

market data only

market data only

No id means no account. get_ticker, get_candles and get_symbol_spec work; balances, positions, leverage and every order call raise. Use an id-less connection to read one exchange while trading on another:

signal = Connection.get(Connection.BINANCE).get_market(Market.SPOT)
trade = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid").get_market(Market.SPOT)

One id belongs to one exchange for the whole script. Opening the same id on a second exchange is an error.

Which id to write in QuantPilot. Strategies in QuantPilot use id="mock_hyperliquid". In backtests and paper trading this connection is funded with a simulated USDC wallet. When you start the strategy live, QuantPilot binds it to the exchange connection you choose at start-up; you do not edit the id yourself.

A Connection has three read-only attributes: id, name (human-readable) and exchange (for example "hyperliquid").

conn.get_market(instrument)

Returns the Market for one instrument on the connection. Raises if the exchange does not offer that instrument.

Constant

Meaning

Market.SPOT

Spot

Market.LINEAR_PERPETUAL

USD-margined perpetual

Market.INVERSE_PERPETUAL

Coin-margined perpetual

Market.LINEAR_FUTURE

USD-margined dated future

Market.INVERSE_FUTURE

Coin-margined dated future

Hyperliquid offers Market.SPOT and Market.LINEAR_PERPETUAL. Asking a Hyperliquid connection for any other instrument raises.

conn = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid")
spot = conn.get_market(Market.SPOT)
perp = conn.get_market(Market.LINEAR_PERPETUAL)

A Market has one attribute, instrument (for example "spot").

Symbols

Symbols follow the unified BASE/QUOTE format; a perpetual adds the settlement currency after a colon.

Instrument

Example

Spot

"BTC/USDC"

Linear perpetual

"BTC/USDC:USDC"

The validator checks every symbol literal against the catalogue of supported symbols and rejects one it does not know before the strategy runs, so a typo is caught early. Note that the check is against the supported catalogue as a whole, not against the exchange you named: a symbol can pass validation and still not exist on that exchange. On Hyperliquid, everything is quoted in USDC; there are no USDT pairs.


Market data

market.get_ticker(symbol)

The current price snapshot for one symbol. Raises if no data has arrived yet for this symbol.

Returns: Ticker

Attribute

Type

Description

bid

Decimal

Best bid

ask

Decimal

Best ask

last

Decimal

Last trade price

quote_volume

Decimal

24-hour volume in the quote currency

updated_at

datetime

Time of the last update (UTC)

stale

bool

True if the snapshot has not been refreshed within the freshness window. Always False in a backtest

ticker = perp.get_ticker("BTC/USDC:USDC")
spread = ticker.ask - ticker.bid
if ticker.stale:
log.warning("ticker is stale, skipping this tick")
return

market.get_candles(symbol, timeframe=..., limit=...)

Historical candles for one symbol.

Parameter

Type

Required

Notes

symbol

string

yes

timeframe

string

yes

One of "1m", "5m", "15m", "30m", "1h", "4h", "1d". Anything else is rejected by the validator.

limit

int

no

Number of candles. Default 200, maximum 1,000; a higher value is capped at 1,000. Pass it explicitly. The validator warns when it is omitted, because an indicator needs enough bars to warm up.

Returns: CandleSeries

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=300)

current_close = candles.closes[0] # most recent
previous_close = candles.closes[1] # one bar earlier
oldest_close = candles.closes[-1] # 300 bars ago

Series are indexed newest-first

Every series in QuantScript (candle columns and indicator outputs) uses reverse indexing: index 0 is the most recent value, 1 the one before it, and -1 the oldest. This matches PineScript and most trading tools. It does not match Python lists, so be deliberate when you loop.

A series is not a number

A series is a sequence, not a value. Comparing or doing arithmetic on it directly is a validation error. Index it first.

conn = Connection.get(Connection.HYPERLIQUID, id="mock_hyperliquid")
perp = conn.get_market(Market.LINEAR_PERPETUAL)

def Run():
candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)
rsi = ta.rsi(candles, length=14)
if rsi > Decimal("50"): # ERROR: comparing a series
pass
candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)
rsi = ta.rsi(candles, length=14)
if rsi[0] > Decimal("50"): # OK: comparing the latest value
pass

Indicators that return several series come back as a tuple. Unpack first, then index.

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)
macd_line, signal_line, histogram = ta.macd(candles)
if macd_line[0] > signal_line[0]:
pass

CandleSeries

Returned by get_candles. Supports indexing (candles[0] is the latest Candle), slicing (candles[0:5], candles[-5:]), iteration and len().

Attribute

Returns

Description

opens

DecimalSeries

Open prices

highs

DecimalSeries

High prices

lows

DecimalSeries

Low prices

closes

DecimalSeries

Close prices

volumes

DecimalSeries

Volumes

candles = spot.get_candles("BTC/USDC", timeframe="4h", limit=200)
bullish_bars = 0
for candle in candles[0:10]:
if candle.close > candle.open:
bullish_bars = bullish_bars + 1

Candle

One candlestick, usually reached by indexing a CandleSeries.

Attribute

Type

Description

symbol

string

Trading pair

timestamp

int

Unix time in milliseconds

open, high, low, close

Decimal

Prices

volume

Decimal

Volume

market.get_symbol_spec(symbol)

The contract specification for a symbol: the metadata you need for order sizing and leverage checks. Raises if the market has no specification for the symbol.

Returns: SymbolSpec

Attribute

Type

Description

symbol

string

Trading pair

exchange

string

For example "hyperliquid"

instrument

string

For example "linear_perpetual"

contract_size

Decimal

Underlying units per contract

max_leverage

Decimal

Highest leverage the exchange allows for this symbol

spec = perp.get_symbol_spec("BTC/USDC:USDC")
wanted = Decimal("20")
if spec.max_leverage >= wanted:
perp.set_leverage("BTC/USDC:USDC", leverage=wanted)
else:
perp.set_leverage("BTC/USDC:USDC", leverage=spec.max_leverage)

Balances, account, positions and leverage

These calls read the money state of the account behind a market. They need an account-backed connection (one opened with an id). Three facts apply to all of them:

  • Backtests: the wallet is simulated and funded from the run's capital, so the calls work on every exchange.

  • Live: the data comes from the exchange's real-time balance and position streams. Today only Hyperliquid provides these streams. On other exchanges the calls raise live, and QuantScript has no try/except, so a strategy meant to run live elsewhere cannot read balances or positions at all.

  • Check stale before acting. Every money-state object carries a stale flag. It means the stream is down or reconnecting, not the data is old. Balances only change when you trade, so a quiet stream is not stale. Never open new exposure on stale data; closing a position is usually still the right call.

market.get_balance(currency)

The real exchange balance of one currency, from the wallet behind this market: the spot wallet for spot, the derivatives wallet for perpetuals, or the unified wallet on exchanges that have one. A currency you do not hold returns a zero balance, not an error. Live, the call raises until the first balance snapshot has arrived.

Returns: Balance

Attribute

Type

Description

currency

string

For example "USDC"

free

Decimal

Available to trade

used

Decimal

Locked by open orders or margin

total

Decimal

free + used

updated_at

datetime

Time of the exchange's last frame (UTC)

stale

bool

True while the balance stream is down or re-establishing

usdc = spot.get_balance("USDC")
if usdc.stale:
return
if usdc.free > Decimal("100"):
log.info("spendable USDC: " + usdc.free.string())

free and total are not interchangeable. free excludes quote currency reserved by your own resting limit orders, so size new entries from free, and compute fractions of the portfolio from total.

Portfolio value on spot. A spot wallet has no single equity number: after a buy, value has moved from the quote currency into the base asset. Mark the holdings to market when you need it:

symbol = "BTC/USDC"
base_ccy = symbol.split("/")[0]
quote_ccy = symbol.split("/")[1]

quote = spot.get_balance(quote_ccy)
base = spot.get_balance(base_ccy)
price = spot.get_ticker(symbol).last

equity = quote.total + base.total * price # portfolio value, marked to market
spendable = quote.free # what a new entry may consume

You often need neither. A strategy that trades a fixed quantity or a fixed notional already knows its size; reach for balances only when the sizing rule is a fraction of the portfolio.

market.get_account()

The exchange's own summary of the account that funds this market: equity, withdrawable collateral, margin in use and floating PnL, in the account's reference currency. On unified-account exchanges every market of a connection returns the same account.

Raises on spot markets: a spot wallet carries no margin, so the exchange has no equity figure to report. Use get_balance there. Also raises when the simulated wallet is funded in more than one currency, because there is then no single currency to express equity in.

Returns: Account

Attribute

Type

Description

currency

string

Reference currency of the numbers below, for example "USDC"

equity

Decimal

Account value including unrealized PnL. The number the exchange uses for margin checks

available

Decimal

Withdrawable now. Conservative on cross-margin accounts: unrealized profit counts as margin for new positions but cannot be withdrawn

margin_used

Decimal

Margin locked by open positions

unrealized_pnl

Decimal

Floating PnL over open positions. Already included in equity

updated_at

datetime

Time of the exchange's last frame (UTC)

stale

bool

Same meaning as on Balance

acc = perp.get_account()
if acc.stale:
return
risk_budget = acc.equity * Decimal("0.02")
log.info("risking " + risk_budget.string() + " " + acc.currency)

Per-position numbers (liquidation price, margin of one position) are on Position, not here.

market.get_position(symbol, side=...)

The open position for a symbol. Flat is a zero position, not an error: is_open is False, size is 0 and side is "", and the object still carries stale, so you can tell "flat and the data is fresh" from "flat because the stream is down" before re-entering. Live, the call raises until the first position snapshot has arrived. Spot symbols never have a position, so spot reads are always flat.

Parameter

Type

Required

Notes

symbol

string

yes

side

string

no

Default "both": the single open leg, whichever direction. If hedge mode is on at the exchange and two legs exist, the default returns a zero position. Pass "long" or "short" to pick one.

Returns: Position

Attribute

Type

Description

symbol

string

Trading pair

side

string

"long", "short", or "" when flat

is_open

bool

True when a leg is open. Reflects the last snapshot; check stale

size

Decimal

Position size in the exchange's position units (base currency on Hyperliquid)

entry_price

Decimal

Average entry price

notional

Decimal

Position value in quote currency

unrealized_pnl

Decimal

Exchange-reported unrealized PnL

liquidation_price

Decimal

Exchange-reported liquidation price

margin_used

Decimal

Margin locked by this position

leverage

Decimal

Current leverage

margin_mode

string

"isolated" or "cross"

updated_at

datetime

Time of the exchange's last frame (UTC)

stale

bool

Same meaning as on Balance

Position frames are whole-set snapshots: a symbol missing from the exchange's next frame is closed and is served as a zero position from then on.

pos = perp.get_position("BTC/USDC:USDC")
if pos.stale:
return # stream down: do not act on possibly outdated data

price = perp.get_ticker("BTC/USDC:USDC").last
if pos.is_open and pos.side == "long":
liq_distance = (price - pos.liquidation_price) / price
if liq_distance < Decimal("0.02"):
perp.place_order(pos.symbol, side=Order.SELL, type=Order.MARKET,
quantity=pos.size, reduce_only=True)

market.set_leverage(symbol, leverage=...)

Sets the leverage for a symbol on this market. It applies to the next position opened on that symbol, in backtests and live alike. Only isolated margin is supported.

Parameter

Type

Required

Notes

symbol

string

yes

leverage

Decimal

yes

Must be a positive whole number

margin_mode

string

no

Only "isolated" (the default) is accepted

Returns: None

The engine does not check the request against the exchange's ceiling. Compare with get_symbol_spec(symbol).max_leverage first (see the example under get_symbol_spec).

perp.set_leverage("BTC/USDC:USDC", leverage=Decimal("5"))

Orders

All order methods live on the market. Constants for side and type live on Order.

Constant

Value

Order.BUY

"buy"

Order.SELL

"sell"

Order.MARKET

"market"

Order.LIMIT

"limit"

market.place_order(symbol, side=..., type=..., quantity=..., ...)

Places an order and returns it.

Parameter

Type

Required

Notes

symbol

string

yes

side

constant

yes

Order.BUY or Order.SELL

type

constant

yes

Order.MARKET or Order.LIMIT

quantity

Decimal

yes

Amount in base currency

price

Decimal

for limit orders

Ignored for market orders

time_in_force

string

no

"GTC" (default), "IOC" or "FOK"

post_only

bool

no

Limit orders only: place only if it would not match immediately. Default False

reduce_only

bool

no

The order may only reduce or close a position, never open or grow one. Default False

Returns: Order

# Market buy on spot
order = spot.place_order("BTC/USDC", side=Order.BUY, type=Order.MARKET, quantity=Decimal("0.01"))
state["last_order_id"] = order.id

# Limit buy on the perpetual
perp.place_order(
"BTC/USDC:USDC",
side=Order.BUY,
type=Order.LIMIT,
quantity=Decimal("0.05"),
price=Decimal("60000"),
post_only=True,
)

# Close a long safely: reduce_only can never open or grow a position
pos = perp.get_position("BTC/USDC:USDC")
if pos.is_open and pos.side == "long":
perp.place_order(pos.symbol, side=Order.SELL, type=Order.MARKET,
quantity=pos.size, reduce_only=True)

Stop and stop-limit order types are not available yet.

The Order object

Attribute

Type

Description

id

string

QuantPilot's order id (a UUID). Pass it to get_order and cancel_order

symbol

string

Trading pair

side

string

"BUY" or "SELL"

type

string

"MARKET" or "LIMIT"

status

string

"NEW", "PLACED", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED" or "EXPIRED"

quantity

Decimal

Order quantity in base currency

price

Decimal or None

Limit price; None for market orders

error_code

string or None

Why the order was rejected or cancelled, for example "insufficient_funds", "wallet_not_funded", "collateral_lost". Not yet readable from a script. See the note below

error_message

string or None

The same reason in full. Not yet readable from a script

Only these attributes exist. Fill price, executed amount, fees and timestamps are not on the order.

The runtime fills in error_code and error_message, but the validator does not yet accept a script that reads them, so a strategy cannot use them today. Until that changes, branch on status alone; the reason appears in the backtest's order log.

The constants are lowercase; the returned strings are uppercase. Order.BUY is "buy", but a returned order's side is "BUY". So order.side == Order.BUY is always False. When you inspect an order you got back, compare against the uppercase strings: order.side == "BUY", order.status == "FILLED".

Rejections differ between backtest and live. In a backtest, an order the wallet cannot back comes back with status == "REJECTED" and an error_code, so the strategy can decide what to do. Live, place_order raises instead and the tick ends. A check like if order.status == "REJECTED" therefore works in a backtest and never fires live.

order = perp.place_order("BTC/USDC:USDC", side=Order.BUY, type=Order.MARKET, quantity=Decimal("0.1"))
if order.status == "REJECTED":
log.warning("order rejected; skipping entry this tick")

market.cancel_order(order_id)

Cancels one order and returns the updated Order.

pending = state.get("pending_order_id")
if pending != None:
perp.cancel_order(pending)
state.pop("pending_order_id", None)

market.cancel_all_orders(symbol=...)

Cancels every open order on this market, or only those for one symbol. Returns the cancelled orders as a list.

cancelled = perp.cancel_all_orders("BTC/USDC:USDC")
log.info("cancelled %d orders" % len(cancelled))

market.get_order(order_id)

Returns one order by id. Raises if it is not found.

pending = state.get("pending_order_id")
if pending != None:
order = perp.get_order(pending)
if order.status == "FILLED":
state["entry_price"] = order.price
state.pop("pending_order_id", None)

market.get_open_orders(symbol=...)

Returns the working orders on this market, all symbols or one. Returns a list of Order.

if len(perp.get_open_orders("BTC/USDC:USDC")) == 0:
perp.place_order("BTC/USDC:USDC", side=Order.BUY, type=Order.LIMIT,
quantity=Decimal("0.05"), price=Decimal("60000"))

market.get_closed_orders(symbol=...)

Returns finished orders (filled, cancelled, rejected, expired) on this market, all symbols or one. Returns a list of Order.

filled = 0
for order in perp.get_closed_orders("BTC/USDC:USDC"):
if order.status == "FILLED":
filled = filled + 1
log.info("%d filled orders so far" % filled)

Technical analysis: ta

ta holds the built-in indicators. Each takes a CandleSeries as its first argument (named candles) and returns a DecimalSeries, or a tuple of them for multi-line indicators. Indicators run natively in the engine, so always prefer a ta function over a hand-written loop.

Five indicators (sma, ema, wma, smma and std_dev) also accept a DecimalSeries as input, which lets you chain them: ta.sma(ta.rsi(candles, length=14), length=5) is a smoothed RSI.

Every series is indexed newest-first, and a series must be indexed before it is compared (rsi[0] > Decimal("50")).

Moving averages

Function

Returns

Notes

ta.sma(candles, length)

DecimalSeries

Simple moving average of closes. Accepts a DecimalSeries too

ta.ema(candles, length)

DecimalSeries

Exponential moving average. Accepts a DecimalSeries too

ta.wma(candles, length)

DecimalSeries

Linearly weighted: newest bar weighted length, oldest weighted 1. Accepts a DecimalSeries too

ta.smma(candles, length)

DecimalSeries

Smoothed (Wilder's / RMA) moving average. Accepts a DecimalSeries too

ta.hma(candles, length)

DecimalSeries

Hull moving average. length must be at least 2

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)
ema_fast = ta.ema(candles, length=12)
ema_slow = ta.ema(candles, length=26)

if ta.cross_above(ema_fast, ema_slow):
log.info("bullish EMA cross")

rsi_smoothed = ta.sma(ta.rsi(candles, length=14), length=5)
if rsi_smoothed[0] < Decimal("30"):
log.info("smoothed RSI oversold")

Momentum and oscillators

Function

Returns

Notes

ta.rsi(candles, length)

DecimalSeries

Relative Strength Index, 0 to 100

ta.macd(candles, fast=12, slow=26, signal=9)

(macd_line, signal_line, histogram)

All three are DecimalSeries

ta.roc(candles, length)

DecimalSeries

Rate of change in percent: 100 * (close - close[length]) / close[length]

ta.mom(candles, length)

DecimalSeries

Momentum: close - close[length]

ta.cci(candles, length)

DecimalSeries

Commodity Channel Index, typically -200 to +200

ta.stochastic(candles, k_period, k_smoothing=1, d_period=1)

(k, d)

Stochastic oscillator %K and %D

ta.mfi(candles, length)

DecimalSeries

Money Flow Index, 0 to 100 (volume-weighted RSI)

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)

rsi = ta.rsi(candles, length=14)
if ta.cross_above(rsi, Decimal("30")):
log.info("RSI crossed back above 30")

macd_line, signal_line, histogram = ta.macd(candles, fast=12, slow=26, signal=9)
if ta.cross_above(histogram, Decimal("0")):
log.info("MACD histogram turned positive")

stoch_k, stoch_d = ta.stochastic(candles, k_period=14, k_smoothing=3, d_period=3)
if stoch_k[0] < Decimal("20") and ta.cross_above(stoch_k, stoch_d):
log.info("stochastic bullish cross in the oversold zone")

Volatility and bands

Function

Returns

Notes

ta.atr(candles, length)

DecimalSeries

Average True Range

ta.std_dev(candles, length)

DecimalSeries

Rolling population standard deviation. Accepts a DecimalSeries too

ta.bollinger_bands(candles, length, stddev=2.0)

(lower, middle, upper)

Bands around an SMA

ta.keltner_channels(candles, length, multiplier=2.0, atr_length=10)

(lower, basis, upper)

basis is an EMA; the bands are basis ± multiplier × ATR

ta.super_trend(candles, atr_length=10, multiplier=3.0)

(direction, value)

direction is +1 in an uptrend and -1 in a downtrend; value is the trailing-stop line

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)

atr = ta.atr(candles, length=14)
stop_distance = atr[0] * Decimal("2")

lower, middle, upper = ta.bollinger_bands(candles, length=20, stddev=2.0)
if candles.closes[0] <= lower[0]:
log.info("price at the lower band")

direction, trail = ta.super_trend(candles, atr_length=10, multiplier=3.0)
if direction[0] > Decimal("0") and direction[1] <= Decimal("0"):
log.info("SuperTrend flipped up; stop at " + trail[0].string())

Trend and volume

Function

Returns

Notes

ta.adx(candles, length)

DecimalSeries

Average Directional Index, 0 to 100; higher means a stronger trend

ta.dmi(candles, length)

(plus_di, minus_di, adx)

Directional Movement Index

ta.sar(candles, acceleration=0.02, maximum=0.2)

DecimalSeries

Parabolic SAR

ta.vwap(candles)

DecimalSeries

Volume-Weighted Average Price

ta.obv(candles)

DecimalSeries

On-Balance Volume (cumulative). Compare its direction, not its absolute value

candles = perp.get_candles("BTC/USDC:USDC", timeframe="4h", limit=200)

plus_di, minus_di, adx = ta.dmi(candles, length=14)
if adx[0] > Decimal("25") and plus_di[0] > minus_di[0]:
log.info("strong uptrend")

sar = ta.sar(candles)
if candles.closes[0] > sar[0]:
log.info("SAR below price: uptrend")

Cross helpers

Function

Returns

Notes

ta.cross_above(series, target)

bool

True on the bar where series moves from at-or-below target to above it

ta.cross_below(series, target)

bool

The mirror image

target can be another DecimalSeries, a Decimal, an int or a float.

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)
ema_slow = ta.ema(candles, length=50)

if ta.cross_above(candles.closes, ema_slow):
log.info("price broke above the 50 EMA")
if ta.cross_below(ta.rsi(candles, length=14), Decimal("70")):
log.info("RSI fell back under 70")

Windows without a built-in indicator

Use the aggregation methods on a DecimalSeries for rolling highs, lows, sums and averages instead of writing loops.

candles = perp.get_candles("BTC/USDC:USDC", timeframe="1h", limit=200)

# Donchian channel over the last 20 bars, including the bar that is still forming
dc_upper = candles.highs.max(20)
dc_lower = candles.lows.min(20)
dc_mid = (dc_upper + dc_lower) / Decimal("2")

# Breakout signals must not see the current bar: slice it off first
closed_upper = candles.highs[1:21].max(20)
if candles.closes[0] > closed_upper:
log.info("20-bar breakout")

avg_volume = candles.volumes.avg(20)
if candles.volumes[0] > avg_volume * Decimal("2"):
log.info("volume spike")

Numbers, series and time

Decimal

Use Decimal for everything that is money, price or quantity. Never use float for these: floats cannot represent values like 0.1 exactly and the error compounds. Every price and quantity the API returns is already a Decimal.

Construct from a string whenever you can. Decimal(0.1) first builds a binary float and then converts it, carrying the float's error along; Decimal("0.1") is exact.

qty = Decimal("0.1")
price = Decimal("104500.50")
total = qty * price # exact
rounded = total.round(2)
log.info("total " + rounded.string())

Method

Returns

Description

d.string()

string

Text form, up to 10 decimal places

d.float()

float

Lossy conversion

d.int()

int

Whole part, truncated toward zero

d.abs()

Decimal

Absolute value

d.round(places)

Decimal

Round to places decimal places (banker's rounding)

d.truncate(places)

Decimal

Cut to places decimal places without rounding

Decimal supports + - * / // %, unary minus, all comparisons, and mixing with int. A zero Decimal is falsy. Passing an unparseable value (Decimal("abc")) raises and ends the tick.

DecimalSeries

The type returned by every indicator and by the candle columns (closes, highs, and so on). Indexed newest-first. Supports indexing, slicing (series[0:5], series[1:21]), iteration, len() and membership tests (value in series).

It cannot be compared or used in arithmetic directly. Index it first.

Method

Returns

Description

series.sum(length)

Decimal

Sum of the last length values

series.avg(length)

Decimal

Mean of the last length values

series.max(length)

Decimal

Highest of the last length values

series.min(length)

Decimal

Lowest of the last length values

If length is larger than the series, the whole series is used.

datetime

Spelled lowercase, like Python's. All times are UTC. Immutable.

datetime.now() returns the current time. In a backtest this is the simulated time of the bar being processed, not the wall clock.

datetime(year, month, day, hour=0, minute=0, second=0, microsecond=0) builds a specific moment.

Attribute or method

Returns

Description

year, month, day, hour, minute, second, microsecond

int

Components

tzinfo

string

Always "UTC"

timestamp()

float

Seconds since 1970-01-01 UTC

weekday()

int

Monday is 0, Sunday is 6

replace(...)

datetime

Copy with the given components replaced

strftime(format)

string

Format with Python codes such as %Y-%m-%d %H:%M

Arithmetic: datetime + timedelta, datetime - timedelta, and datetime - datetime (which gives a timedelta). All comparisons work.

now = datetime.now()
if now.weekday() >= 5:
return # no trading at the weekend

last_trade = state.get("last_trade_at")
if last_trade != None and now - last_trade < timedelta(hours=4):
return # still in the cooldown

state["last_trade_at"] = now # datetime values persist in state

timedelta

timedelta(days=0, hours=0, minutes=0, seconds=0, microseconds=0). Attributes days and seconds (the remainder after whole days); method total_seconds(). Supports +, -, unary minus and comparisons. Can be stored in state.

math

math mirrors Python's math module for a subset of functions. Every function takes and returns float (or int where natural). Use it for non-monetary calculations only; keep money in Decimal.

Group

Functions

Constants

math.pi, math.e, math.tau, math.inf, math.nan

Powers and logs

sqrt, pow, exp, log, log10, log2

Trigonometry

sin, cos, tan, asin, acos, atan, atan2, sinh, cosh, tanh, asinh, acosh, atanh

Rounding (return int)

ceil, floor, trunc, round (banker's rounding)

Other

abs, fabs, copysign, fmod, hypot, degrees, radians

Checks

isnan, isinf, isfinite

Random

random() in [0, 1); randint(a, b) inclusive


Logging and notifications

log

log.info(message), log.warning(message) and log.error(message) write to the strategy's log, which you can read in QuantPilot. Logs are diagnostics: they are never sent to you. A message longer than 1,000 characters is trimmed in the stored log.

ticker = perp.get_ticker("BTC/USDC:USDC")
log.info("last=%s spread=%s" % (ticker.last.string(), (ticker.ask - ticker.bid).string()))

There are no f-strings in Starlark. Build messages with + or the % operator.

notification

notification.send(...) is how a strategy tells you something worth your attention. In a live strategy it is delivered as a Telegram message. In a backtest nothing is sent; every notification is recorded so you can read back what the strategy would have told you.

notification.send(message, channel=notification.TELEGRAM, data={...})

Parameter

Required

Notes

message

yes

Positional. Up to 1,000 characters

channel

yes

Keyword. Currently only notification.TELEGRAM. There is deliberately no default

data

no

Keyword. A flat dictionary of extra fields, up to 16 entries. Keys up to 64 characters, values up to 256 characters after conversion, 4,096 bytes in total

Returns: None

ticker = perp.get_ticker("BTC/USDC:USDC")
if ticker.last > Decimal("100000"):
notification.send(
"BTC crossed 100k",
data={"symbol": "BTC/USDC:USDC", "price": ticker.last},
channel=notification.TELEGRAM,
)

Values in data become strings: int, bool, Decimal (same text as .string()), datetime (RFC 3339) and float are accepted. Nested dictionaries, lists, None and API objects are rejected. Prefer Decimal over float for numbers a reader will look at. A float carries its noise into the text (0.30000000000000004).

Delivery is best effort. A notification never blocks or stops your strategy. Three things drop one:

  • More than 20 send calls in one Run(). From the 21st on, calls are dropped silently; the count resets every tick. A loop over thirty symbols that notifies on each loses ten.

  • A messaging outage, or a burst larger than the delivery buffer.

  • A payload that cannot be encoded, which the size limits above are designed to prevent.

A dropped notification is not retried: a message that says "position closed" twenty minutes late is a wrong message, not a late one. Size violations, by contrast, are errors and end the tick, so they surface in the backtest long before going live.


Language rules and limits

QuantScript is Starlark, so most everyday Python works: if/elif/else, for loops with break and continue, functions, lists, dictionaries, tuples, comprehensions, lambdas, string methods (split, upper, startswith, replace, join), % formatting and the ternary a if cond else b.

What differs from Python:

Not available

Use instead

while loops

for i in range(n):

try / except

Nothing. An error ends the current tick, and the next tick runs normally. Check inputs (stale, is_open, status) before acting

class

Functions and dictionaries

f-strings

"%s at %s" % (a, b) or +

is / is not

== None / != None

del x[k]

state.pop(k); for local dicts, d.pop(k)

import

Everything is predeclared (see below)

open, eval, exec, compile

Not permitted

Predeclared names. These exist in every script and must not be redefined: Connection, Market, Order, Decimal, datetime, timedelta, ta, state, log, notification, math.

Validation before the first tick. Every script is checked before it runs. Besides syntax, the validator rejects: a missing Run() or a Run() with parameters; control flow or bare calls at the top level; a series used in a comparison or arithmetic without indexing; unknown attributes and methods; an unsupported timeframe, instrument or exchange constant; a symbol outside the supported catalogue; and any v1 API name. It also reports warnings (an unused variable, unreachable code, a name that shadows a built-in) which do not stop the script but are worth reading.

Limits.

What

Limit

Candles per get_candles call

1,000 (default 200)

state

1,000 keys, 256,000 bytes; kept 7 days after the strategy stops

log line

trimmed to 1,000 characters in the stored log

notification message

1,000 characters (error if longer)

notification data

16 keys, 64-character keys, 256-character values, 4,096 bytes total

Notifications per tick

20; extra calls are dropped

Leverage

positive whole number; isolated margin only

Orders per backtest

200,000; the backtest stops when exceeded

Ticks that raise an error, per backtest

1,000; the backtest is failed when exceeded

Performance. Indicators run natively; a hand-written RSI in Starlark is slower and usually wrong. Compute each series once per tick and keep it in a variable. For rolling windows use sum, avg, max and min on a series rather than a loop.


Known limitations

  • Live balances, positions and account data are Hyperliquid-only today. get_balance, get_account and get_position need the exchange's real-time streams; on Binance, Bybit and Coinbase they raise in a live strategy. In backtests they work everywhere. Market data works everywhere, live and backtest.

  • Order types are market and limit only. There are no stop or stop-limit orders yet.

  • Margin mode is isolated only. Cross margin cannot be selected from a script.

  • Order.error_code and Order.error_message are filled in by the engine but not yet accepted by the validator, so a script cannot read them.

  • No error recovery inside a script. There is no try/except; an error ends the tick, and Run() is called again on the next one. A backtest in which 1,000 ticks raise is failed.

  • Live rejections raise. A REJECTED order status is only observable in a backtest; live, place_order raises instead.

  • One id, one exchange. A connection id cannot be reopened on a second exchange in the same script.

  • state holds scalars only (up to 1,000 keys and 256,000 bytes) and is kept for 7 days after a strategy stops.

  • Notifications are Telegram-only and best effort: at most 20 per tick, no retries.

Did this answer your question?