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
Decimalwhen you use them for money or price math:Decimal(str(StrategyParams["rsi_oversold"])).Read
StrategyParamsinsideRun(); never write to it. When a user changes a parameter, QuantPilot rewrites the dictionary in the source and saves a new version.StrategyParamsholds inputs the strategy reads. Values the strategy writes each tick (counters, entry prices, order ids) belong instate.Do not put your total capital in
StrategyParams. The engine wallet holds it, and your strategy reads it withget_balance()orget_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 absentOperation | What it does |
| Store a value. Storing |
| Read. Raises if the key is absent. On the first tick nothing is set yet, so prefer |
| Read with a fallback. |
| Remove the key and return its value. Without a default, raises if absent. |
| Membership test. |
| Count and iterate keys. Iteration order is unspecified. |
| List of |
| 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 |
| constant | yes |
|
| 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 | that exchange, with a simulated wallet | your account on that exchange |
no | 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 |
| Spot |
| USD-margined perpetual |
| Coin-margined perpetual |
| USD-margined dated 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 |
|
Linear perpetual |
|
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 |
|
| Best bid |
|
| Best ask |
|
| Last trade price |
|
| 24-hour volume in the quote currency |
|
| Time of the last update (UTC) |
|
|
|
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 |
| string | yes |
|
| string | yes | One of |
| 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
passIndicators 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 |
|
| Open prices |
|
| High prices |
|
| Low prices |
|
| Close prices |
|
| 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 |
| string | Trading pair |
| int | Unix time in milliseconds |
|
| Prices |
|
| 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 |
| string | Trading pair |
| string | For example |
| string | For example |
|
| Underlying units per contract |
|
| 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
stalebefore acting. Every money-state object carries astaleflag. 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 |
| string | For example |
|
| Available to trade |
|
| Locked by open orders or margin |
|
|
|
|
| Time of the exchange's last frame (UTC) |
|
|
|
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 |
| string | Reference currency of the numbers below, for example |
|
| Account value including unrealized PnL. The number the exchange uses for margin checks |
|
| Withdrawable now. Conservative on cross-margin accounts: unrealized profit counts as margin for new positions but cannot be withdrawn |
|
| Margin locked by open positions |
|
| Floating PnL over open positions. Already included in |
|
| Time of the exchange's last frame (UTC) |
|
| Same meaning as on |
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 |
| string | yes |
|
| string | no | Default |
Returns: Position
Attribute | Type | Description |
| string | Trading pair |
| string |
|
|
|
|
|
| Position size in the exchange's position units (base currency on Hyperliquid) |
|
| Average entry price |
|
| Position value in quote currency |
|
| Exchange-reported unrealized PnL |
|
| Exchange-reported liquidation price |
|
| Margin locked by this position |
|
| Current leverage |
| string |
|
|
| Time of the exchange's last frame (UTC) |
|
| Same meaning as on |
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 |
| string | yes |
|
|
| yes | Must be a positive whole number |
| string | no | Only |
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 |
|
|
|
|
|
|
|
|
market.place_order(symbol, side=..., type=..., quantity=..., ...)
Places an order and returns it.
Parameter | Type | Required | Notes |
| string | yes |
|
| constant | yes |
|
| constant | yes |
|
|
| yes | Amount in base currency |
|
| for limit orders | Ignored for market orders |
| string | no |
|
|
| no | Limit orders only: place only if it would not match immediately. Default |
|
| no | The order may only reduce or close a position, never open or grow one. Default |
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 |
| string | QuantPilot's order id (a UUID). Pass it to |
| string | Trading pair |
| string |
|
| string |
|
| string |
|
|
| Order quantity in base currency |
|
| Limit price; |
| string or | Why the order was rejected or cancelled, for example |
| string or | 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 |
|
| Simple moving average of closes. Accepts a |
|
| Exponential moving average. Accepts a |
|
| Linearly weighted: newest bar weighted |
|
| Smoothed (Wilder's / RMA) moving average. Accepts a |
|
| Hull moving average. |
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 |
|
| Relative Strength Index, 0 to 100 |
|
| All three are |
|
| Rate of change in percent: |
|
| Momentum: |
|
| Commodity Channel Index, typically -200 to +200 |
|
| Stochastic oscillator %K and %D |
|
| 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 |
|
| Average True Range |
|
| Rolling population standard deviation. Accepts a |
|
| Bands around an SMA |
|
|
|
|
|
|
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 |
|
| Average Directional Index, 0 to 100; higher means a stronger trend |
|
| Directional Movement Index |
|
| Parabolic SAR |
|
| Volume-Weighted Average Price |
|
| 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 |
|
|
|
|
| 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 |
| string | Text form, up to 10 decimal places |
| float | Lossy conversion |
| int | Whole part, truncated toward zero |
|
| Absolute value |
|
| Round to |
|
| Cut to |
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 |
|
| Sum of the last |
|
| Mean of the last |
|
| Highest of the last |
|
| Lowest of the last |
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 |
| int | Components |
| string | Always |
| float | Seconds since 1970-01-01 UTC |
| int | Monday is 0, Sunday is 6 |
|
| Copy with the given components replaced |
| string | Format with Python codes such as |
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 |
|
Powers and logs |
|
Trigonometry |
|
Rounding (return |
|
Other |
|
Checks |
|
Random |
|
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 |
| yes | Positional. Up to 1,000 characters |
| yes | Keyword. Currently only |
| 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
sendcalls in oneRun(). 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 |
|
|
| Nothing. An error ends the current tick, and the next tick runs normally. Check inputs ( |
| Functions and dictionaries |
f-strings |
|
|
|
|
|
| Everything is predeclared (see below) |
| 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 | 1,000 (default 200) |
| 1,000 keys, 256,000 bytes; kept 7 days after the strategy stops |
| trimmed to 1,000 characters in the stored log |
| 1,000 characters (error if longer) |
| 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_accountandget_positionneed 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_codeandOrder.error_messageare 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, andRun()is called again on the next one. A backtest in which 1,000 ticks raise is failed.Live rejections raise. A
REJECTEDorder status is only observable in a backtest; live,place_orderraises instead.One
id, one exchange. A connection id cannot be reopened on a second exchange in the same script.stateholds 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.
