Skip to main content

Change what a strategy does

Copy-paste recipes for common edits: change the symbol or timeframe, position size, stop-loss and take-profit, trailing stop, leverage, Telegram alerts, cooldowns, indicators, limit orders and stale-data checks.

Recipes for the edits people most often make to a strategy the agent wrote. Each assumes the skeleton from Anatomy of a QuantScript strategy: conn opened at the top level, spot and/or perp markets obtained from it, and the code below placed inside Run() (or a helper it calls). After any edit, click Validate, then run a backtest.

Change the symbol or timeframe

Edit StrategyParams, either in the form or in the code:

StrategyParams = {
"symbol": "ETH/USDC",
"timeframe": "4h",
}

Symbols are BASE/QUOTE for spot ("ETH/USDC") and BASE/QUOTE:SETTLE for a perpetual ("ETH/USDC:USDC"). Hyperliquid quotes everything in USDC; there are no USDT pairs. Timeframes must be one of 1m, 5m, 15m, 30m, 1h, 4h, 1d. Validation rejects anything else, and it rejects a symbol it does not know, so a typo is caught before the first tick.

If the strategy trades a perpetual, the market must match: perp = conn.get_market(Market.LINEAR_PERPETUAL) and a :USDC symbol. A spot symbol on a perpetual market (or the reverse) fails at run time, not in validation.

Change the position size

Four common sizing rules. Pick one and keep the rest of the strategy unchanged.

A fraction of spendable balance (spot):

quote = spot.get_balance("USDC")
if quote.stale:
return
allocation = Decimal(str(StrategyParams.get("capital_allocation_pct", 0.25)))
quantity = (quote.free * allocation / spot.get_ticker("BTC/USDC").last).round(5)

A fixed quantity of the base asset:

quantity = Decimal(str(StrategyParams.get("base_quantity", 0.01)))
spot.place_order("BTC/USDC", side=Order.BUY, type=Order.MARKET, quantity=quantity)

A fixed notional in the quote currency:

notional = Decimal(str(StrategyParams.get("order_size_usdc", 500)))
price = spot.get_ticker("BTC/USDC").last
quantity = (notional / price).round(5)

A fraction of account equity, with leverage (perpetual):

acc = perp.get_account()
if acc.stale:
return
allocation = Decimal(str(StrategyParams.get("capital_allocation_pct", 0.10)))
leverage = Decimal(str(StrategyParams.get("leverage_value", 5)))
price = perp.get_ticker("BTC/USDC:USDC").last
quantity = (acc.equity * allocation * leverage / price).round(4)

Keep allocations below 100%. The engine's funds check is exact, so an order sized at the whole balance is rejected on any price movement between reading the balance and filling.

Add or change a stop-loss and take-profit

On spot, where the strategy remembers its entry in state:

price = spot.get_ticker("BTC/USDC").last
entry = state.get("entry_price")
if entry != None:
gain = (price - entry) / entry
if gain <= -Decimal(str(StrategyParams.get("stop_loss_pct", 0.05))):
spot.place_order("BTC/USDC", side=Order.SELL, type=Order.MARKET, quantity=state["quantity"])
state.pop("entry_price", None)
state.pop("quantity", None)
log.info("stop-loss hit at " + price.string())

On a perpetual, where the exchange knows the position:

pos = perp.get_position("BTC/USDC:USDC")
price = perp.get_ticker("BTC/USDC:USDC").last
if pos.is_open:
is_long = pos.side == "long"
gain = (price - pos.entry_price) / pos.entry_price if is_long else (pos.entry_price - price) / pos.entry_price
close_side = Order.SELL if is_long else Order.BUY
if gain >= Decimal(str(StrategyParams.get("take_profit_pct", 0.05))) or gain <= -Decimal(str(StrategyParams.get("stop_loss_pct", 0.03))):
perp.place_order(pos.symbol, side=close_side, type=Order.MARKET, quantity=pos.size, reduce_only=True)

reduce_only=True guarantees the order can only shrink or close the position. It can never flip you into the opposite direction by mistake.

Add a trailing stop

Keep the highest price seen since entry in state and exit when price falls a set distance below it. This version uses ATR for the distance so it adapts to volatility.

candles = spot.get_candles("BTC/USDC", timeframe="1h", limit=200)
price = spot.get_ticker("BTC/USDC").last
atr = ta.atr(candles, length=14)

if state.get("in_trade", False):
peak = state.get("peak_price", price)
if price > peak:
peak = price
state["peak_price"] = peak

trail = atr[0] * Decimal(str(StrategyParams.get("atr_multiplier", 2.0)))
if price <= peak - trail:
spot.place_order("BTC/USDC", side=Order.SELL, type=Order.MARKET, quantity=state["quantity"])
state["in_trade"] = False
state.pop("peak_price", None)
log.info("trailing stop at " + price.string())

Act once per candle instead of every tick

Any strategy that reads a ticker runs every simulated minute in a backtest, so a crossover computed on the still-forming 1-hour bar can flicker. To evaluate once per closed bar, remember the newest candle's timestamp:

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

Trade both directions on a perpetual

Replace the spot entry with a two-sided one driven by the exchange's position:

symbol = "BTC/USDC:USDC"
candles = perp.get_candles(symbol, timeframe="1h", limit=200)
fast = ta.ema(candles, length=9)
slow = ta.ema(candles, length=21)
pos = perp.get_position(symbol)
if pos.stale:
return

quantity = Decimal("0.01")
if not pos.is_open:
if ta.cross_above(fast, slow):
perp.place_order(symbol, side=Order.BUY, type=Order.MARKET, quantity=quantity)
elif ta.cross_below(fast, slow):
perp.place_order(symbol, side=Order.SELL, type=Order.MARKET, quantity=quantity)
elif pos.side == "long" and ta.cross_below(fast, slow):
perp.place_order(symbol, side=Order.SELL, type=Order.MARKET, quantity=pos.size, reduce_only=True)
elif pos.side == "short" and ta.cross_above(fast, slow):
perp.place_order(symbol, side=Order.BUY, type=Order.MARKET, quantity=pos.size, reduce_only=True)

To flip directly from long to short in one tick, close first (reduce_only) and open the new side in the same Run(); do not send one oversized order.

Set leverage safely

Leverage applies to the next position opened on that symbol. The engine does not check your request against the exchange's maximum, so read it first, and set it once, not every tick:

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

Only isolated margin is available; leverage must be a whole number.

Add a Telegram alert

price = perp.get_ticker("BTC/USDC:USDC").last
if price > Decimal(str(StrategyParams.get("alert_above", 100000))):
notification.send(
"BTC above " + price.string(),
data={"symbol": "BTC/USDC:USDC", "price": price},
channel=notification.TELEGRAM,
)

Live, this is a Telegram message. In a backtest it is recorded, not sent, so you can see what the strategy would have told you. At most 20 notifications per tick are delivered; the rest are dropped silently. log.info is not an alert. It only writes to the strategy log.

Add a cooldown or a time-of-day filter

now = datetime.now()

if now.weekday() >= 5:
return # weekends off

last = state.get("last_trade_at")
if last != None and now - last < timedelta(hours=int(StrategyParams.get("cooldown_hours", 4))):
return # too soon after the last trade

# ... entry logic; after placing an order:
state["last_trade_at"] = now

datetime.now() is the simulated time in a backtest, so the same code backtests correctly.

Swap or add an indicator

All indicators take the candles first and return a series indexed newest-first. Multi-line indicators return a tuple: unpack it, then index. Five of them (sma, ema, wma, smma, std_dev) also accept another indicator's output, so you can smooth one indicator with another.

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

adx = ta.adx(candles, length=14) # trend strength filter
lower, middle, upper = ta.bollinger_bands(candles, length=20, stddev=2.0)
rsi_smooth = ta.sma(ta.rsi(candles, length=14), length=5)

if adx[0] > Decimal("25") and candles.closes[0] <= lower[0] and rsi_smooth[0] < Decimal("35"):
log.info("trend + band touch + oversold")

The full list with signatures is in the reference under Technical analysis.

Use a limit order instead of a market order

symbol = "BTC/USDC:USDC"
price = perp.get_ticker(symbol).last

if len(perp.get_open_orders(symbol)) == 0 and state.get("want_entry", False):
order = perp.place_order(symbol, side=Order.BUY, type=Order.LIMIT,
quantity=Decimal("0.01"), price=(price * Decimal("0.995")).round(1),
post_only=True)
state["pending_order_id"] = order.id

pending = state.get("pending_order_id")
if pending != None:
order = perp.get_order(pending)
if order.status == "FILLED":
state.pop("pending_order_id", None)
state["want_entry"] = False
elif order.status in ("CANCELED", "REJECTED", "EXPIRED"):
state.pop("pending_order_id", None)

post_only=True makes the order rest on the book instead of filling immediately. Check status against the uppercase strings.

Skip trading on stale data

Every money-state object (Ticker, Balance, Account, Position) has a stale flag meaning "the data stream is down or reconnecting". Refuse new exposure on stale data; still allow exits.

pos = perp.get_position("BTC/USDC:USDC")
acc = perp.get_account()
money_fresh = not pos.stale and not acc.stale

if pos.is_open and state.get("exit_signal", False):
perp.place_order(pos.symbol, side=Order.SELL if pos.side == "long" else Order.BUY,
type=Order.MARKET, quantity=pos.size, reduce_only=True)

if not pos.is_open and money_fresh and state.get("entry_signal", False):
perp.place_order("BTC/USDC:USDC", side=Order.BUY, type=Order.MARKET, quantity=Decimal("0.01"))

Did this answer your question?