Skip to main content

Anatomy of a QuantScript strategy

A complete agent-written strategy explained line by line, the patterns every QuantPilot strategy shares, and the same strategy on a perpetual. Start here if you want to understand code the agent wrote for you.

Below is a complete strategy of the kind the agent produces: it buys BTC on the Hyperliquid spot market when a fast moving average crosses above a slow one, sizes the buy as a fraction of the USDC it can spend, and sells on a profit target, a stop-loss or the opposite cross. Read the whole thing once, then the walkthrough underneath. Every strategy you will meet has the same skeleton.

StrategyParams = {
"symbol": "BTC/USDC",
"timeframe": "1h",
"fast_length": 9,
"slow_length": 21,
"rsi_length": 14,
"rsi_exit_level": 70,
"take_profit_pct": 0.10,
"stop_loss_pct": 0.05,
"capital_allocation_pct": 0.25,
}

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


def exit_reason(gain, rsi_now, bearish_cross):
take_profit = Decimal(str(StrategyParams["take_profit_pct"]))
stop_loss = Decimal(str(StrategyParams["stop_loss_pct"]))
rsi_exit = Decimal(str(StrategyParams["rsi_exit_level"]))
if gain >= take_profit and rsi_now >= rsi_exit:
return "take profit"
if gain <= -stop_loss:
return "stop loss"
if bearish_cross:
return "bearish cross"
return None


def Run():
symbol = StrategyParams["symbol"]
quote_ccy = symbol.split("/")[1]

ticker = spot.get_ticker(symbol)
if ticker.stale:
return
price = ticker.last

candles = spot.get_candles(symbol, timeframe=StrategyParams["timeframe"], limit=200)
fast_ema = ta.ema(candles, length=StrategyParams["fast_length"])
slow_ema = ta.ema(candles, length=StrategyParams["slow_length"])
rsi = ta.rsi(candles, length=StrategyParams["rsi_length"])

in_trade = state.get("in_trade", False)

# --- Entry ---
if not in_trade and ta.cross_above(fast_ema, slow_ema):
wallet = spot.get_balance(quote_ccy)
if wallet.stale:
return
allocation = Decimal(str(StrategyParams["capital_allocation_pct"]))
budget = wallet.free * allocation
if budget <= Decimal("0"):
log.warning("no spendable " + quote_ccy + "; skipping entry")
return

quantity = (budget / price).round(5)
order = spot.place_order(symbol, side=Order.BUY, type=Order.MARKET, quantity=quantity)
if order.status == "REJECTED":
log.warning("entry rejected")
return

state["in_trade"] = True
state["entry_price"] = price
state["quantity"] = quantity
log.info("BUY " + quantity.string() + " at " + price.string())
return

# --- Exit ---
if in_trade:
entry_price = state["entry_price"]
quantity = state["quantity"]

gain = (price - entry_price) / entry_price
reason = exit_reason(gain, rsi[0], ta.cross_below(fast_ema, slow_ema))

if reason != None:
spot.place_order(symbol, side=Order.SELL, type=Order.MARKET, quantity=quantity)
state["in_trade"] = False
state.pop("entry_price", None)
state.pop("quantity", None)
log.info("SELL at " + price.string() + " (" + reason + ")")
notification.send(
"Closed BTC spot trade: " + reason,
data={"exit_price": price, "entry_price": entry_price},
channel=notification.TELEGRAM,
)

Reading it, top to bottom

The settings block

StrategyParams = {
"symbol": "BTC/USDC",
"timeframe": "1h",
...
"capital_allocation_pct": 0.25,
}

StrategyParams is the one dictionary QuantPilot shows you as a form. Every number a person might want to tune lives here: symbol, timeframe, indicator lengths, thresholds, sizing. The strategy only reads it. When you change a value in the form, QuantPilot rewrites this block and saves a new version; you never need to touch the rest of the code for a tuning change.

Values are plain numbers and strings. Whenever the code needs one of them for a price or money calculation, it converts it first: Decimal(str(StrategyParams["take_profit_pct"])). That conversion is deliberate. See Decimal in the reference.

The connection and the market

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

Two lines that run once, when the strategy starts. Connection.get opens an exchange account; get_market picks one instrument on it: spot here, Market.LINEAR_PERPETUAL for a perpetual. Everything the strategy does with prices, orders and balances goes through spot.

id="mock_hyperliquid" is the id QuantPilot strategies use. In a backtest it is funded with a simulated USDC wallet; when you start the strategy live, QuantPilot binds it to the exchange connection you pick at start-up. You do not edit it.

Helper functions

def exit_reason(gain, rsi_now, bearish_cross):
...
return None

Anything defined with def at the top level is a helper you can call from Run(). Only two kinds of statement are allowed at the top level: assignments (like the two blocks above) and def. An if or a bare function call outside a function is rejected before the strategy starts.

Run(): the part that repeats

def Run():
symbol = StrategyParams["symbol"]
quote_ccy = symbol.split("/")[1]

The engine calls Run() once per tick: about once a second live, and in a backtest at every boundary of the smallest timeframe the strategy reads (once per simulated minute here, because get_ticker counts as one minute). Everything that must happen repeatedly is inside it. Variables created here vanish when the tick ends.

Reading the price and the candles

ticker = spot.get_ticker(symbol)
if ticker.stale:
return
price = ticker.last

candles = spot.get_candles(symbol, timeframe=StrategyParams["timeframe"], limit=200)
fast_ema = ta.ema(candles, length=StrategyParams["fast_length"])
slow_ema = ta.ema(candles, length=StrategyParams["slow_length"])
rsi = ta.rsi(candles, length=StrategyParams["rsi_length"])

get_ticker returns the latest price snapshot; if the snapshot is stale the strategy simply does nothing this tick. get_candles returns the last 200 candles of the chosen timeframe (the limit), and the ta functions turn them into indicator series. Series are indexed newest-first: rsi[0] is the current RSI, rsi[1] the previous bar's.

Remembering where we are

in_trade = state.get("in_trade", False)

state is the strategy's memory. Unlike ordinary variables it survives from tick to tick and across restarts. The strategy reads it with .get(key, default) because on the very first tick nothing has been stored yet and a bare state["in_trade"] would raise.

Entering

if not in_trade and ta.cross_above(fast_ema, slow_ema):
wallet = spot.get_balance(quote_ccy)
if wallet.stale:
return
allocation = Decimal(str(StrategyParams["capital_allocation_pct"]))
budget = wallet.free * allocation
...
quantity = (budget / price).round(5)
order = spot.place_order(symbol, side=Order.BUY, type=Order.MARKET, quantity=quantity)
if order.status == "REJECTED":
log.warning("entry rejected")
return

ta.cross_above is True only on the bar where the fast line moves from below the slow line to above it, so the entry fires once per cross, not on every bar the fast line is higher.

Sizing reads the real wallet: wallet.free is the USDC not tied up in other orders, and the strategy spends a quarter of it (capital_allocation_pct). Because it reads the wallet rather than a fixed number, the size follows the account as it grows or shrinks.

place_order returns the order. In a backtest an order the wallet cannot afford comes back with status == "REJECTED" (live it raises instead), so the strategy checks and stands down rather than recording a trade that never happened.

Note the comparison is against the uppercase string "REJECTED". The Order.* constants are lowercase and are for sending orders, not for reading them back.

state["in_trade"] = True
state["entry_price"] = price
state["quantity"] = quantity

Three facts about the trade go into state so the exit logic, possibly hours later and possibly after a restart, knows what it holds.

Exiting

if in_trade:
entry_price = state["entry_price"]
quantity = state["quantity"]
gain = (price - entry_price) / entry_price
reason = exit_reason(gain, rsi[0], ta.cross_below(fast_ema, slow_ema))
if reason != None:
spot.place_order(symbol, side=Order.SELL, type=Order.MARKET, quantity=quantity)
state["in_trade"] = False
state.pop("entry_price", None)
state.pop("quantity", None)

The helper decides whether any exit rule applies and names it; the exit sells exactly the quantity that was bought and clears the memory. state.pop(key, None) deletes a key without complaining if it is missing (there is no del in QuantScript).

notification.send(
"Closed BTC spot trade: " + reason,
data={"exit_price": price, "entry_price": entry_price},
channel=notification.TELEGRAM,
)

log.* lines go to the strategy log for you to read later; notification.send is the one that reaches you: a Telegram message live, a stored record in a backtest.

Patterns you will see in every agent-written strategy

  • All tunables in StrategyParams; converted with Decimal(str(...)) where money math needs them.

  • Connection and markets opened once at the top; helpers as def; everything else inside Run().

  • stale checked before acting on prices, balances, positions or account data; exits are usually still allowed on stale data, new entries are not.

  • Spot strategies remember their position in state; perpetual strategies ask the exchange with get_position instead, and close with reduce_only=True.

  • Order results compared against uppercase strings ("FILLED", "REJECTED"); order requests built with the lowercase constants (Order.BUY, Order.MARKET).

  • Indicators from ta, never hand-rolled; series indexed (rsi[0]) before any comparison.

The same skeleton on a perpetual

For comparison, here is the agent's version of the same idea on the BTC perpetual, long and short. The differences are the ones listed above: the position comes from the exchange (get_position) rather than state, closes use reduce_only=True, sizing reads get_account().equity and applies leverage, and new entries are refused while the position or account data is stale.

StrategyParams = {
"symbol": "BTC/USDC:USDC",
"timeframe": "1h",
"leverage_value": 5,
"fast_length": 9,
"slow_length": 21,
"capital_allocation_pct": 0.10,
"take_profit_pct": 0.05,
"stop_loss_pct": 0.03,
}

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


def close_position(pos):
side = Order.SELL if pos.side == "long" else Order.BUY
perp.place_order(pos.symbol, side=side, type=Order.MARKET,
quantity=pos.size, reduce_only=True)


def Run():
symbol = StrategyParams["symbol"]

if not state.get("leverage_set", False):
spec = perp.get_symbol_spec(symbol)
wanted = Decimal(str(StrategyParams["leverage_value"]))
perp.set_leverage(symbol, leverage=wanted if wanted <= spec.max_leverage else spec.max_leverage)
state["leverage_set"] = True

ticker = perp.get_ticker(symbol)
if ticker.stale:
return
price = ticker.last

candles = perp.get_candles(symbol, timeframe=StrategyParams["timeframe"], limit=200)
fast_ema = ta.ema(candles, length=StrategyParams["fast_length"])
slow_ema = ta.ema(candles, length=StrategyParams["slow_length"])
bullish_cross = ta.cross_above(fast_ema, slow_ema)
bearish_cross = ta.cross_below(fast_ema, slow_ema)

pos = perp.get_position(symbol)

# --- Exit: allowed even on stale data, since it only reduces risk ---
if pos.is_open:
take_profit = Decimal(str(StrategyParams["take_profit_pct"]))
stop_loss = Decimal(str(StrategyParams["stop_loss_pct"]))

if pos.side == "long":
gain = (price - pos.entry_price) / pos.entry_price
if gain >= take_profit or gain <= -stop_loss or bearish_cross:
close_position(pos)
log.info("closed long at " + price.string())
elif pos.side == "short":
gain = (pos.entry_price - price) / pos.entry_price
if gain >= take_profit or gain <= -stop_loss or bullish_cross:
close_position(pos)
log.info("closed short at " + price.string())
return

# --- Entry: needs fresh money state ---
if not (bullish_cross or bearish_cross):
return

acc = perp.get_account()
if pos.stale or acc.stale:
return

allocation = Decimal(str(StrategyParams["capital_allocation_pct"]))
leverage = Decimal(str(StrategyParams["leverage_value"]))
notional = acc.equity * allocation * leverage
quantity = (notional / price).round(4)
if quantity <= Decimal("0"):
return

side = Order.BUY if bullish_cross else Order.SELL
order = perp.place_order(symbol, side=side, type=Order.MARKET, quantity=quantity)
if order.status == "REJECTED":
log.warning("entry rejected; equity " + acc.equity.string())
return
log.info("opened " + order.side + " " + quantity.string() + " at " + price.string())

Did this answer your question?