Skip to content

Strategy authoring

A strategy declares the history it needs and defines one Python function:

HISTORY = {"primary": 1}


def evaluate(ctx):
    ...
    return None  # or a Signal

The engine ingests every bar in order but calls evaluate(ctx) only after every view declared in HISTORY is ready. From then on, each primary bar gets a fresh StrategyContext (ctx) describing the market and your account as of that bar, and returns an optional Signal. Returning None means "do nothing this bar".

This page is the hand-written reference for that function: the ctx surface you read, the Signal you return, the rules the sandbox enforces, and the idiomatic patterns for common needs. For the frozen typed contract (exact signatures, every field and its type) see the SDK reference; for complete, runnable strategies see Examples.

Risk is engine-managed

Stops are optional: return Signal(direction="LONG") with no stop_loss/take_profit for exit-on-signal strategies, then return Signal(direction="FLAT") when your exit condition fires. The engine sizes every order and enforces leverage, margin, and kill-switches; you can declare a default RISK profile and read the resolved values via ctx.risk. See Risk management.

Names are pre-injected: do not import

Inside evaluate, the names Signal, np (NumPy), pd (pandas), and math are already in scope. The sandbox injects them. Do not import anything: imports are rejected by the validator (see Authoring rules).

The ctx surface

ctx is read-only market and account state. You never mutate it (except ctx.state, which is yours; see Cross-bar state); you read from it and decide whether to return a Signal.

Market data

Field Type Notes
ctx.candles CandleSeries Bounded rolling history up to and including the current bar.
ctx.candles.close pd.Series Also .open, .high, .low, .volume, .delta, .buy_volume, .sell_volume.
ctx.candles.df pd.DataFrame The current bounded rolling window as a frame, assembled on demand; never the whole run.
ctx.current_bar CandleEvent The latest bar: .close, .high, .low, .open, .volume.
ctx.bar_index int Absolute 1-based counter for the whole run; never a position into ctx.candles.
ctx.current_time datetime Timestamp of the current bar.
ctx.symbol str e.g. "BTCUSDT". Constant for the whole run.
ctx.timeframe str e.g. "1h". Constant for the whole run.
ctx.orderbook always None Strategy-side L2 is disabled in this backend: L2 data drives the fill model, not strategy signals. Build order-flow logic from ctx.candles.delta / .buy_volume / .sell_volume instead.
ctx.latest_funding_rate float or None Most recent funding rate, if available.

The series on ctx.candles are ordinary pandas Series, so the idiomatic way to read "the value on the current bar" is .iloc[-1]:

def evaluate(ctx):
    close = ctx.candles.close
    last_close = close.iloc[-1]
    ...

Declare history instead of guarding it

evaluate() is not called until HISTORY is ready. Size the declaration for the indicator's period and convergence needs, then index relative to the end of the rolling window:

HISTORY = {"primary": 100}


def evaluate(ctx):
    rsi = ctx.indicators.rsi(ctx.candles.close, 14)
    if rsi.iloc[-1] < 30:
        ...

Indicators

ctx.indicators is a small library of cached indicators. Each takes one or more pd.Series and returns a pd.Series (unless noted):

Call Returns
ctx.indicators.sma(series, period) pd.Series
ctx.indicators.ema(series, period) pd.Series
ctx.indicators.rsi(series, period=14) pd.Series
ctx.indicators.atr(high, low, close, period=14) pd.Series
ctx.indicators.cvd(delta, offset=0.0) pd.Series
ctx.indicators.choppiness(high, low, close, period=14) pd.Series
ctx.indicators.williams_fractals(high, low, width) (fractal_highs, fractal_lows)
ctx.indicators.keltner(high, low, close, period=20, atr_period=10, mult=2.0) (upper, middle, lower)
ctx.indicators.wma(series, period) pd.Series
ctx.indicators.trima(series, period) pd.Series
ctx.indicators.hull_ma(series, period) pd.Series
ctx.indicators.efficiency_ratio(series, period=10) pd.Series (0..1)
ctx.indicators.kama(series, period=10, fast=2, slow=30) pd.Series
ctx.indicators.williams_r(high, low, close, period=14) pd.Series (−100..0)
ctx.indicators.mfi(high, low, close, volume, period=14) pd.Series (0..100)
ctx.indicators.cmo(series, period=14) pd.Series (±100)
ctx.indicators.stoch_rsi(series, period=14, stoch_period=14, smooth_k=3, smooth_d=3) (k, d)
ctx.indicators.tsi(series, slow=25, fast=13) pd.Series (±100)
ctx.indicators.trix(series, period=15, signal=9) (trix, signal)
ctx.indicators.fisher_transform(high, low, period=10) (fisher, signal)
ctx.indicators.inverse_fisher_rsi(series, period=14, smooth_period=9) pd.Series (−1..1)
ctx.indicators.vwap(high, low, close, volume) pd.Series (UTC-session anchored)
ctx.indicators.rolling_vwap(high, low, close, volume, period=20) pd.Series
ctx.indicators.pivot_points(high, low, close) (p, r1, s1, r2, s2)
ctx.indicators.linreg_slope(series, period=14) pd.Series
ctx.indicators.linreg_forecast(series, period=14, ahead=0) pd.Series
ctx.indicators.linreg_r2(series, period=14) pd.Series (0..1)
ctx.indicators.linreg_channel(series, period=100, num_std=2.0) (upper, middle, lower)
ctx.indicators.parabolic_sar(high, low, af_start=0.02, af_step=0.02, af_max=0.2) (sar, direction)
ctx.indicators.divergence(price, osc, width=2, lookback=90, method="peaks") pd.Series (−1/0/+1)
HISTORY = {"primary": 53}


def evaluate(ctx):
    close = ctx.candles.close
    fast = ctx.indicators.ema(close, 12)
    slow = ctx.indicators.ema(close, 26)
    if fast.iloc[-1] > slow.iloc[-1] and ctx.position is None:
        return Signal(direction="LONG")
    return None

Higher timeframes (HTF)

To read a higher timeframe, call ctx.get_htf(timeframe). It returns a TimeframeView exposing .candles and .indicators for that timeframe, so you can compute HTF indicators the same way you compute base-timeframe ones.

Declare every HTF you use

A timeframe is only available if you declare it at module level, above def evaluate, with additional_timeframes:

HISTORY = {"primary": 1, "4h": 20}
additional_timeframes = ('4h',)        # one HTF
# additional_timeframes = ('4h', '1d') # multiple

def evaluate(ctx):
    htf = ctx.get_htf('4h')
    htf_sma = htf.indicators.sma(htf.candles.close, 20)
    ...

Calling ctx.get_htf('4h') without declaring it raises KeyError: No HTF data for '4h'.

Rules enforced by the platform:

  • At most 3 additional timeframes per strategy.
  • Valid labels: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d.
  • Every declared timeframe must be a strictly-higher exact multiple of the run's base timeframe: e.g. '4h' works on a 15m or 1h backtest but not on a 1d backtest. Incompatible submissions are rejected with an instructive error before the run starts.
  • HTF views are empty until the first complete bucket closes, but you need no guard for that: give the timeframe a HISTORY entry (see Declaring history) and the engine withholds evaluate() until the view holds that many closed buckets.

ctx.htf is the underlying Dict[str, TimeframeView] keyed by your declared timeframes. Prefer ctx.get_htf(tf) for access; use tf in ctx.htf only for declared-HTF membership checks.

Base timeframes vs HTF labels are different sets

The base timeframe is the one you pick for the run itself (ctx.timeframe). The submittable base timeframes are 1m, 5m, 15m, 1h, 4h, 1d. The HTF labels above (1m … 1d, the full list) are what you may declare as a higher timeframe, a wider set. The two are independent: a label like 3m is a legal HTF to declare but not a legal base to run on. 5m is a legal base timeframe; 5m candles are synthesized live from 1m data (there is no separate 5m candle store).

Cross-bar state

ctx.state is a plain dict that persists across bars within one run and is reset between runs. It is yours: the engine never reads or writes it. Use it for counters, cooldowns, multi-leg flags, peak-equity trackers: anything you need to remember from one bar to the next.

def evaluate(ctx):
    if 'green_count' not in ctx.state:
        ctx.state['green_count'] = 0
    ...

This is the only place your strategy keeps memory: there is no trade history or previous-signal API (see Limits).

Position and portfolio

ctx.position is a PositionView when you hold a position in ctx.symbol, and None otherwise. (It is a shortcut for ctx.portfolio.position(ctx.symbol).)

if ctx.position is None:
    # flat — eligible to enter
    ...

PositionView fields:

Field Notes
.symbol str
.side "LONG" or "SHORT"
.qty float, signed (positive = LONG, negative = SHORT)
.size float, unsigned (= abs(qty))
.avg_entry_price float
.unrealized_pnl float (USDT)
.unrealized_pnl_pct float already in percent (2.0 means +2%)
.opened_at datetime
.bars_since_entry int
.stop_loss float or None: entry-time SL from the Signal; not live-tracked
.take_profit float or None: entry-time TP; not live-tracked
.add_count int: same-direction DCA merges since open

ctx.portfolio is the account snapshot across all symbols:

Field Notes
ctx.portfolio.equity float: total account equity (USDT)
ctx.portfolio.cash float: available cash (USDT)
ctx.portfolio.open_orders_count int
ctx.portfolio.open_positions tuple of PositionView across all symbols (usually one)

.stop_loss / .take_profit are the entry levels, not live state

These reflect the SL/TP you set on the entry Signal. .take_profit is never updated; .stop_loss moves only when the built-in trailing (trailing_stop_distance) or break-even (breakeven_at_profit_pct) mechanics ratchet it. The engine itself handles the actual exit when a level is hit. Reading them is only for your own reasoning (e.g. a near-stop check).

The Signal contract

Returning a Signal is the only way to act on the engine. Its essential shape:

Signal(
    direction="LONG" | "SHORT" | "FLAT",
    stop_loss=<absolute price>,
    take_profit=<absolute price>,
    confidence=0.0-1.0,
    setup_id="my-setup",
)
  • direction: "LONG", "SHORT", or "FLAT". "FLAT" is an explicit exit: it closes the open position for ctx.symbol at market (no-op when you are already flat). It is the exit half of an exit-on-signal strategy.
  • stop_loss / take_profit: absolute price levels, not percentages and not distances. A stop at "2% below entry" is computed by you into a price before you pass it. Omit both on a "FLAT" signal.
  • confidence: 0.0 to 1.0, optional.
  • setup_id: a label for the setup, optional.
def evaluate(ctx):
    price = ctx.current_bar.close
    if entry_condition(ctx) and ctx.position is None:
        return Signal(
            direction="LONG",
            stop_loss=price * 0.98,    # absolute price 2% below
            take_profit=price * 1.04,  # absolute price 4% above
            setup_id="breakout",
        )
    if exit_condition(ctx) and ctx.position is not None:
        return Signal(direction="FLAT")  # exit-on-signal: close at market
    return None

Entry signals can also carry three engine-managed exit mechanics: max_hold_bars=<N> (time exit: auto-flatten after N bars), trailing_stop_distance=<absolute distance> (ratcheting trailing stop; requires stop_loss), and breakeven_at_profit_pct=<N> (stop moves to entry once N% in profit; requires stop_loss).

For the full constructor, including the additional fields on the typed contract (order_type, entry_price, add_to_position, risk_pct, metadata), see Signal in the SDK reference.

SL/TP brackets and adding to a position (DCA)

SL/TP are absolute price levels. When you add to an existing position with a same-direction Signal that omits SL/TP, the engine resizes the existing brackets to cover the larger total position but keeps their absolute prices: the brackets do not re-anchor to the new weighted-average entry.

To move the brackets after an add, re-specify stop_loss / take_profit on the add Signal: the protective bracket orders are cancelled and re-created, re-anchored to the add's fill price (the original risk/reward distances preserved). Note, however, that ctx.position.stop_loss / ctx.position.take_profit keep reporting the original entry's levels: those read-only fields are not updated on an add, so don't rely on them to reflect the re-anchored orders.

Beware: DCA-ing past your own stop can leave the absolute SL at or through your new average entry: the position can be underwater against a stop that no longer sits where you'd expect relative to your average price.

Authoring rules

The sandbox validates your source before it ever runs. Within evaluate (and any top-level helper functions):

  • stop_loss and take_profit must be absolute prices (not percentages).
  • evaluate(ctx) returns Optional[Signal]: a Signal, or None.
  • No imports. np, pd, math, Signal, and the adaptive risk/stop helpers are pre-injected; everything else is off-limits. Use Signal(risk_pct=...) for a per-signal risk override and the adaptive dispatchers (resolve_stop, resolve_risk, rr_take_profit, atr_trail_distance) for stops/TPs (see Risk management).
  • No class definitions: only top-level functions.
  • No file I/O, no exec/eval, no network calls.

You may define additional top-level helper functions and call them from evaluate; the additional_timeframes declaration (see HTF) also lives at module level.

Declaring history

Every strategy must declare how much history it needs, as a module-level HISTORY literal dict above def evaluate. One entry per view you read, each count in that view's own bars:

HISTORY = {"primary": 200}                # primary timeframe only
HISTORY = {"primary": 200, "4h": 50}      # also reads ctx.get_htf("4h")
  • "primary" is required: the largest number of primary-timeframe bars your logic needs (your biggest indicator period plus warm-up).
  • Each higher-timeframe entry is the bars that view needs. An EMA(50) on 4h is "4h": 50, never pre-multiplied by the timeframe ratio. The engine scales it for whichever base timeframe the run selects, which is why the declaration stays runnable on every compatible base.
  • Declare an entry for every timeframe in additional_timeframes, and no others. The two sets must match exactly: a declared higher timeframe with no HISTORY entry, or a HISTORY entry for a timeframe you never declared, is a validation error.
  • LOOKBACK = N is accepted as shorthand for HISTORY = {"primary": N}. Declare one form or the other, never both.

Why it is mandatory. ctx.candles is a bounded rolling window and the engine sizes it (and its activation gate) from this declaration. Without one the window defaults to 200 bars and never exceeds 250, so a guard like if len(ctx.candles) < 300 can never open and the strategy silently never trades while the run still reports success. A missing or malformed declaration is a hard validation error, and the submit endpoint also refuses a date range that cannot hold the requirement.

No warm-up guard needed. The engine withholds evaluate() until every declared view holds the bars you declared: the primary window and each higher timeframe, each counted in its own bars. Declare the number and read history directly:

HISTORY = {"primary": 300, "4h": 50}
additional_timeframes = ("4h",)


def evaluate(ctx):
    # 300 primary bars and 50 closed 4h buckets are guaranteed here.
    ema = ctx.indicators.ema(ctx.candles.close, 200)
    htf = ctx.get_htf("4h")
    slow = htf.indicators.sma(htf.candles.close, 50)
    ...

Reading further back than you declared still raises (the window is sized from the same number), and a len() guard demanding more than you declared is a validation error, because it could never open.

ctx.bar_index keeps counting the withheld bars: it is the absolute 1-based count of bars the engine has ingested, so it is already ≥ your requirement on the very first call. It is still not a position into ctx.candles.

Limits. Two separate ceilings:

  • Each entry is an int in [1, 3000]: that bounds one view's rolling window.
  • The requirement resolved on an hourly base must stay within 5000. That is the budget of the pre-run validation harness, which drives your strategy to steady state before you ever submit it; a declaration it could not exercise would be shipped unvalidated.

So {"primary": 200, "1d": 200} (4800 hourly bars) is accepted, since it only allocates 200 bars per view, while {"1d": 250} (6000) is rejected. Use a lower higher timeframe or a smaller period.

Parameters & forward-walk (PARAMS)

A strategy can declare optimizable parameters by assigning a module-level PARAMS dict above def evaluate. Each parameter has a default and an optional sweep, a list of candidate values. Declaring at least one sweep list makes the strategy forward-walkable: forward-walk optimization searches the cartesian product of every sweep, picks the best parameters per in-sample window, and validates them out-of-sample. A strategy with no swept parameter cannot be forward-walked.

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

HISTORY = {"primary": 69}  # two periods of the largest swept EMA (34), plus one


def evaluate(ctx):
    fast_n = ctx.params["fast"]   # forward-walk substitutes a swept value;
    slow_n = ctx.params["slow"]   # a plain backtest uses the declared default.
    ...

You read parameter values via ctx.params["name"], a read-only mapping. Outside of forward-walk (a plain backtest), ctx.params holds each parameter's default. During forward-walk, the engine substitutes the chosen sweep value for that window. Builtin strategies expose ctx.params == {}.

The PARAMS contract

Rule Detail
Shape PARAMS = {"name": {"default": X, "sweep": [...]}}. sweep is optional: omit it to declare a fixed (non-optimized) parameter.
Literal only PARAMS must be a plain literal dict (no function calls, names, or comprehensions). It is read without executing your code.
Value types Each default is an int, float, or bool. Every value in a sweep must match the default's type exactly (bool is not interchangeable with int).
Names Lowercase identifier, max 31 chars (^[a-z_][a-z0-9_]{0,30}$).
lookback reserved lookback is not a valid parameter name: it is reserved by the engine.
Limits At most 8 parameters; each sweep holds 1-25 unique values.
Forward-walkable At least one parameter must have a sweep list, or forward-walk is rejected at submission.

Grid size is capped per tier

The grid size is the product of all sweep lengths (e.g. two 3-value sweeps = 9 combinations). The maximum grid size allowed in a single forward-walk is capped by your subscription tier. Keep sweeps small; the forward-walk modal previews the combination count before you submit.

No-code parameters

No-code strategies declare the same parameters by adding a sweep list to a numeric field in the config. The compiler turns each swept field into a PARAMS entry and rewrites the field to read ctx.params[...], so a no-code strategy flows through the same forward-walk pipeline as a Python one. The fields that accept a sweep and the parameter name each one produces:

Config field Sweep key Parameter name
indicators[i].period indicators[i].sweep <indicator_id>_period (lowercased id)
exit_config.stop_loss_value exit_config.stop_loss_value_sweep stop_loss_value
exit_config.take_profit_value exit_config.take_profit_value_sweep take_profit_value
exit_config.atr_period exit_config.atr_period_sweep atr_period
condition constant RHS {"value": X} {"value": X, "sweep": [...]} <side>_c<idx>_rhs (side = long/short; idx = 0-based position among that side's leaf conditions)

Periods and atr_period sweep over integers; stop_loss_value, take_profit_value, and condition RHS values sweep over floats. Unswept fields stay literal. See the parameterized EMA crossover example for the Python form.

No-code higher timeframes

Any no-code indicator can read a higher timeframe by adding an optional "timeframe" field. It works on named indicators and on the inline dict on the right-hand side of a condition. The compiler derives the additional_timeframes declaration from use and routes each tagged indicator through that timeframe's view. You do not declare anything separately.

Here a 4h EMA trend filter gates a base-timeframe EMA crossover. The first condition is base-timeframe; the second compares a named 4h EMA against an inline 4h EMA on the right-hand side (both carry "timeframe": "4h"):

{
  "lookback": 200,
  "indicators": [
    {"id": "ema_fast", "type": "ema", "series": "close", "period": 20},
    {"id": "ema_slow", "type": "ema", "series": "close", "period": 50},
    {"id": "trend_4h", "type": "ema", "series": "close", "period": 50,
     "timeframe": "4h"}
  ],
  "long_entry": {
    "logic": "AND",
    "conditions": [
      {"left": "ema_fast", "op": ">", "right": "ema_slow"},
      {"left": "trend_4h", "op": ">",
       "right": {"type": "ema", "series": "close", "period": 200,
                 "timeframe": "4h"}}
    ]
  }
}

The same constraints as the Python additional_timeframes contract apply (they share one source of truth):

  • Valid labels: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d.
  • At most 3 distinct higher timeframes across the whole config (counted by distinct label, not by indicator).
  • Every higher timeframe must be a strictly-higher exact multiple of the run's base timeframe (see Higher timeframes).

Activation waits for every declared view

Higher-timeframe views start empty, but the no-code compiler derives their HISTORY requirements and the engine withholds strategy evaluation until every required bucket is closed. You do not add length checks in the config.

Incompatible base/HTF is rejected at submit time

If a declared higher timeframe cannot aggregate from the base timeframe you pick for the run, the run is rejected before it starts with a 422, e.g.:

Strategy declares additional timeframe(s) '4h' that cannot aggregate from
the '1d' base timeframe — an HTF must be a strictly-higher exact multiple
of the base. Compatible base timeframes for this strategy: 1m, 5m, 15m, 1h.

The message lists the base timeframes that would work for the strategy. Backtest and forward-walk return the byte-identical message.

Idiomatic patterns

Common needs and the clean way to express them with the surface above.

Buy on the Nth consecutive green candle. Count in ctx.state:

def evaluate(ctx):
    if 'green_count' not in ctx.state:
        ctx.state['green_count'] = 0
    if ctx.current_bar.close > ctx.current_bar.open:
        ctx.state['green_count'] += 1
    else:
        ctx.state['green_count'] = 0
    if ctx.state['green_count'] >= N and ctx.position is None:
        return Signal(direction="LONG")
    return None

Skip bars while already in a position. Bail early:

if ctx.position is not None:
    return None

Reference sizing off equity. The engine sizes orders from your risk config; this is only for reasoning about stop width, not for placing orders:

# Risk ~1% of equity per trade (reference only — engine executes your SL/TP).
stop_dist = abs(price - sl)
reference_qty = (ctx.portfolio.equity * 0.01) / stop_dist

Wait M bars after a stop-out before re-entering. There is no stop-out event, so detect the in-trade → flat transition yourself and store a cooldown bar:

def evaluate(ctx):
    prev = ctx.state.get('prev_position')
    if prev is not None and ctx.position is None:
        # transitioned from in-trade -> flat this bar: stop-out or TP hit
        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):
        # eligible to enter
        ...
    return None

Higher-timeframe trend filter. Declare the HTF, then gate on it:

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_sma.iloc[-1] > htf.candles.close.iloc[-1]:
        return None
    ...

Near-stop check (read-only; the engine handles the actual stop hit):

if ctx.position and ctx.position.stop_loss is not None:
    dist_pct = abs(price - ctx.position.stop_loss) / price
    if dist_pct < 0.005:
        return None

See Examples for these assembled into complete strategies.

Limits: NOT in the API

These do not exist. Don't reach for them; write your strategy without them.

  • Arbitrary mid-trade stop modification: not supported. Brackets are set on the entry Signal; the only mid-trade movers are the built-in trailing (trailing_stop_distance) and break-even (breakeven_at_profit_pct) mechanics, plus re-specifying SL/TP on a DCA add. For time-based exits use max_hold_bars; for dynamic/indicator exits return Signal(direction="FLAT") (see the Signal contract).
  • ctx.history / ctx.trades / ctx.previous_signals: not exposed. Use ctx.state to remember what you care about (last entry bar, cumulative win count, previous-position snapshot, …).
  • ctx.broker / ctx.cancel_order / ctx.modify_position / ctx.modify_stop: not exposed. The Signal you return is the only way to interact with the engine.
  • ctx.portfolio.drawdown_pct: not exposed. Compute it from ctx.portfolio.equity plus a peak-equity tracker you keep in ctx.state.
  • A "just got stopped out" event: there isn't one. Detect it by watching ctx.position transition from non-None to None across bars (store the previous bar's ctx.position in ctx.state).

Implementation note (as of M5d)

The candle store backing ctx.candles.* is a NumPy ring buffer (CandleRingBuffer). CandleView builds pd.Series lazily and caches them per bar over a shared DatetimeIndex; the lazy .df property assembles the full DataFrame on demand.

The public API is unchanged: ctx.candles.close, ctx.candles.df, .iloc, .name, and everything else above behave identically to before. Forward-walk runs on the same BacktestRunner path and inherits the ring buffer automatically.

You do not need to change anything in your strategy: the ring buffer is a pure performance change behind a stable surface.