Skip to content

Examples

This page shows complete example strategies and short cookbook recipes. The examples are written exactly as you'd write them in the app (no imports and no comments) because the toolkit names (ctx, Signal, np, pd, math) are already provided for you.

See Strategy authoring for the sandbox rules and SDK reference for the full API surface.


EMA crossover

A long-only momentum strategy that enters when the fast EMA (12) crosses above the slow EMA (26) and exits on the reverse cross. Stop-loss is set 3 % below entry; take-profit is set 6 % above entry.

FAST = 12
SLOW = 26

# The engine withholds evaluate() until this much history exists, so
# no warm-up guard is needed.
HISTORY = {"primary": 53}


def evaluate(ctx):

    close = ctx.candles.close
    fast = ctx.indicators.ema(close, FAST)
    slow = ctx.indicators.ema(close, SLOW)

    curr_fast = float(fast.iloc[-1])
    curr_slow = float(slow.iloc[-1])
    prev_fast = float(fast.iloc[-2])
    prev_slow = float(slow.iloc[-2])

    price = float(close.iloc[-1])
    position = ctx.position

    if prev_fast <= prev_slow and curr_fast > curr_slow and position is None:
        return Signal(
            direction="LONG",
            stop_loss=price * 0.97,
            take_profit=price * 1.06,
            setup_id="EMA_CROSS_LONG",
        )

    if prev_fast >= prev_slow and curr_fast < curr_slow and position is not None:
        return Signal(direction="FLAT", setup_id="EMA_CROSS_EXIT")

    return None

EMA crossover with parameters

The same crossover logic, but with the fast and slow EMA lengths declared as forward-walkable parameters. A module-level PARAMS dict gives each one a default and a sweep of candidate values, and evaluate reads them via ctx.params[...]. A plain backtest uses the defaults (12 / 26); forward-walk optimizes over the declared sweeps. See Parameters & forward-walk for the full contract.

PARAMS = {
    "fast": {"default": 12, "sweep": [8, 12, 16]},
    "slow": {"default": 26, "sweep": [21, 26, 34]},
}

# Cover two periods of the largest swept EMA (34), plus the previous bar
# read by the crossover.
HISTORY = {"primary": 69}


def evaluate(ctx):
    fast_n = ctx.params["fast"]
    slow_n = ctx.params["slow"]

    close = ctx.candles.close
    fast = ctx.indicators.ema(close, fast_n)
    slow = ctx.indicators.ema(close, slow_n)

    curr_fast = float(fast.iloc[-1])
    curr_slow = float(slow.iloc[-1])
    prev_fast = float(fast.iloc[-2])
    prev_slow = float(slow.iloc[-2])

    price = float(close.iloc[-1])
    position = ctx.position

    if prev_fast <= prev_slow and curr_fast > curr_slow and position is None:
        return Signal(
            direction="LONG",
            stop_loss=price * 0.97,
            take_profit=price * 1.06,
            setup_id="EMA_CROSS_LONG",
        )

    if prev_fast >= prev_slow and curr_fast < curr_slow and position is not None:
        return Signal(direction="FLAT", setup_id="EMA_CROSS_EXIT")

    return None

RSI mean reversion

Goes long when RSI(14) dips below 30 and short when it pushes above 70; exits when RSI returns to the neutral 45-55 band. Stop-loss and take-profit are expressed as multiples of ATR so the strategy adapts to current volatility.

PERIOD = 14
HISTORY = {"primary": 100}  # several Wilder periods for RSI and ATR convergence
LOWER = 30.0
UPPER = 70.0
EXIT_LOW = 45.0
EXIT_HIGH = 55.0


def evaluate(ctx):
    close = ctx.candles.close
    high = ctx.candles.high
    low = ctx.candles.low

    rsi = float(ctx.indicators.rsi(close, PERIOD).iloc[-1])
    atr = float(ctx.indicators.atr(high, low, close, PERIOD).iloc[-1])

    price = float(close.iloc[-1])
    position = ctx.position

    if position is None:
        if rsi < LOWER:
            return Signal(
                direction="LONG",
                stop_loss=price - 2 * atr,
                take_profit=price + 3 * atr,
                setup_id="RSI_LONG",
            )
        if rsi > UPPER:
            return Signal(
                direction="SHORT",
                stop_loss=price + 2 * atr,
                take_profit=price - 3 * atr,
                setup_id="RSI_SHORT",
            )
        return None

    if EXIT_LOW <= rsi <= EXIT_HIGH:
        return Signal(direction="FLAT", setup_id="RSI_EXIT")

    return None

Cookbook recipes

The snippets below are illustrative fragments, not complete strategies. Paste the relevant block into your evaluate function alongside your entry logic. All identifiers (ctx, Signal, np, pd, math) are pre-injected by the sandbox; do not add import statements in user-submitted code. When a recipe reads more history than your strategy already declares, increase HISTORY to cover it.

Higher-timeframe trend filter

Declare the higher timeframe at module level, then use ctx.get_htf() inside evaluate. The engine raises KeyError if you call get_htf for a timeframe that was not declared.

HISTORY = {"primary": 1, "4h": 20}
additional_timeframes = ("4h",)


def evaluate(ctx):
    htf = ctx.get_htf("4h")
    htf_sma = htf.indicators.sma(htf.candles.close, 20)
    if htf.candles.close.iloc[-1] < htf_sma.iloc[-1]:
        return None

    ...

Cooldown after a stop-out

Detect the in-trade → flat transition via ctx.state and block re-entry for M bars. The engine provides no explicit "stopped out" event, so you track the previous bar's position yourself.

M = 5


def evaluate(ctx):
    prev = ctx.state.get("prev_position")
    if prev is not None and ctx.position is None:
        ctx.state["cooldown_until"] = ctx.bar_index + M
    ctx.state["prev_position"] = ctx.position

    if ctx.position is None and ctx.bar_index < ctx.state.get("cooldown_until", 0):
        return None

    ...

Risk-based stop sizing

Use ctx.portfolio.equity to reason about stop width relative to account size. The engine executes the stop_loss price you pass in the Signal; this pattern just helps you choose where to place it.

def evaluate(ctx):
    price = float(ctx.candles.close.iloc[-1])
    atr = float(ctx.indicators.atr(
        ctx.candles.high, ctx.candles.low, ctx.candles.close, 14
    ).iloc[-1])

    sl = price - 2 * atr
    stop_dist = price - sl

    reference_qty = (ctx.portfolio.equity * 0.01) / stop_dist

    if ctx.position is None:
        return Signal(
            direction="LONG",
            stop_loss=sl,
            take_profit=price + 3 * atr,
            setup_id="ATR_RISK_LONG",
        )
    return None

For the full list of ctx fields, indicator signatures, Signal parameters, and sandbox constraints see SDK reference and Strategy authoring.