Skip to content
Single .md

Supported operators

Complete reference of the operators that declarative mode (entry_conditions / exit_conditions) accepts.

Quick table

OperatorMeaningFires when
crosses_aboveCrosses upwardsource[t-1] <= target[t-1] AND source[t] > target[t]
crosses_belowCrosses downwardsource[t-1] >= target[t-1] AND source[t] < target[t]
crossesAny crosscrosses_above or crosses_below
> (or greater_than)Strictly greatersource[t] > target[t]
>= (or greater_or_equal)Greater or equalsource[t] >= target[t]
< (or less_than)Strictly lesssource[t] < target[t]
<= (or less_or_equal)Less or equalsource[t] <= target[t]
== (or equals)Equal (point-wise)source[t] == target[t]

source[t] is the last value of the series; source[t-1] is the value on the previous candle.

Condition shape

python
{
    "name": "Readable name",             # optional
    "description": "Explanation",        # optional -- tooltip in the UI
    "source": "series_name",             # key in series (required)
    "operator": "crosses_above",         # required
    "target": "other_series",            # key in series, OR
    "value": 30,                         # constant numeric value
    "action": "buy_to_open",             # action fired on trigger
    "enabled": True,                     # if false, the condition is ignored
    "message": "Buy signal",             # optional -- log when triggered
}

Rule: provide either target (series name) or value (constant), not both. If both are provided, the engine prioritizes target.

Crossing operators (discrete)

crosses_above, crosses_below, and crosses fire only on the exact bar of the crossing. Subsequent bars do not fire again (even if the general condition source > target continues to be true).

Example: fast SMA crosses slow upward

python
{
    "name": "Entry Long",
    "source": "ma_fast",       # series computed in _build_chart
    "operator": "crosses_above",
    "target": "ma_slow",       # another series computed in _build_chart
    "action": "buy_to_open",
    "enabled": True,
}

Fires once on the candle where ma_fast[-1] > ma_slow[-1] and ma_fast[-2] <= ma_slow[-2].

Example: RSI crosses 30 upward (oversold recovering)

python
{
    "name": "RSI Reversal Up",
    "source": "rsi",
    "operator": "crosses_above",
    "value": 30,               # constant, not a series
    "action": "buy_to_open",
    "enabled": True,
}

When you use value, the engine compares the series against a fixed value. source[t-1] <= 30 and source[t] > 30.

Continuous comparison operators

>, <, ==, etc. fire on every bar where the condition is true. Useful for filters, but unsuitable for direct entries (they would fire on every bar in which the position is flat).

Correct usage: combined filter

It is not possible to combine multiple conditions within the same entry (it is one per entry). The engine evaluates exit_conditions on every candle; if the condition remains true and sdk.position != 0, the exit fires.

For regime filters, use imperative mode.

Example: close short if RSI goes below 30 (again)

Declarative mode has no "AND". To express "close short when RSI < 30", simply declare:

python
"exit_conditions": [
    {
        "name": "Short Cover by RSI",
        "source": "rsi",
        "operator": "<",        # less_than
        "value": 30,
        "action": "buy_to_cover",
        "enabled": True,
    },
],

Fires on every bar where rsi[-1] < 30, but only while you hold a position: the engine scans exit_conditions whenever sdk.position != 0 and entry_conditions only when flat. Note it does not check whether a condition's action matches the side of the open position -- every condition in the selected list is evaluated, so a buy_to_cover condition in exit_conditions will also fire while long. Coherence between action and position is left to you (and the order layer).

Actions accepted in conditions

Full list (details in canonical actions):

  • buy_to_open -- opens long
  • sell_short_to_open (or sell_short) -- opens short
  • sell_to_close -- closes long
  • buy_to_cover -- closes short
  • close_position -- closes any open position
  • reverse_position -- closes and reverses

Caveat: coherence between condition and action

The engine does not validate whether the action makes sense with the current position. Consider the following case:

python
# Anti-pattern: sell_to_close in entry_conditions
{
    "name": "Sell",
    "operator": "crosses_above",
    "action": "sell_to_close",
    ...
}

The condition fires, but if there is no open long position, the engine ignores it. sell_to_close in entry_conditions does nothing.

Convention:

  • entry_conditions -> buy_to_open or sell_short_to_open.
  • exit_conditions -> sell_to_close or buy_to_cover (or close_position as a generic).

Complete example: declarative RSI mean reversion

python
DECLARATION = {
    "type": "strategy",
    "inputs": [
        {"name": "period", "type": "int", "default": 14, "min": 2, "max": 100},
        {"name": "oversold", "type": "float", "default": 30, "min": 0, "max": 50},
        {"name": "overbought", "type": "float", "default": 70, "min": 50, "max": 100},
    ],
    "plots": [
        {"name": "rsi", "source": "rsi", "type": "line", "color": "#A78BFA"},
    ],
    "pane": "new",
    "entry_conditions": [
        {
            "name": "Buy on oversold",
            "source": "rsi",
            "operator": "crosses_above",
            "value": 30,
            "action": "buy_to_open",
            "enabled": True,
        },
        {
            "name": "Sell on overbought",
            "source": "rsi",
            "operator": "crosses_below",
            "value": 70,
            "action": "sell_short_to_open",
            "enabled": True,
        },
    ],
    "exit_conditions": [
        {
            "name": "Long close at midline",
            "source": "rsi",
            "operator": "crosses_above",
            "value": 50,
            "action": "sell_to_close",
            "enabled": True,
        },
        {
            "name": "Short cover at midline",
            "source": "rsi",
            "operator": "crosses_below",
            "value": 50,
            "action": "buy_to_cover",
            "enabled": True,
        },
    ],
}


def _rsi_series(closes, period):
    # Wilder RSI aligned to `closes` (None during warm-up). Self-contained here;
    # in a live backtest prefer an incremental RSI kept in sdk.state so you do
    # not rescan the full history every bar.
    if len(closes) <= period:
        return [None] * len(closes)
    out = [None] * period
    avg_gain = sum(max(closes[i] - closes[i - 1], 0.0) for i in range(1, period + 1)) / period
    avg_loss = sum(max(closes[i - 1] - closes[i], 0.0) for i in range(1, period + 1)) / period
    out.append(100.0 - 100.0 / (1.0 + (avg_gain / avg_loss if avg_loss else float("inf"))))
    for i in range(period + 1, len(closes)):
        change = closes[i] - closes[i - 1]
        avg_gain = (avg_gain * (period - 1) + max(change, 0.0)) / period
        avg_loss = (avg_loss * (period - 1) + max(-change, 0.0)) / period
        out.append(100.0 - 100.0 / (1.0 + (avg_gain / avg_loss if avg_loss else float("inf"))))
    return out


def _series(closes, params):
    period = int((params or {}).get("period", 14))
    return {"rsi": _rsi_series(closes, period)}


def _build_chart(df, params):
    return {**DECLARATION, "series": _series(list(df["close"]), params)}


def main(df=None, sdk=None, params={}):
    params = params or {}
    if df is not None:
        return _build_chart(df, params)          # chart/study: full rsi series
    if sdk is not None:
        # Declarative fallback path: return the same rsi series so the evaluator
        # has something to cross. Requires params["runtime_declarative_fallback"]
        # = True. WARNING: recomputing rsi over the full sdk.candles every bar is
        # O(n^2) over the backtest — for production keep Wilder avg_gain/avg_loss
        # incrementally in sdk.state instead of rescanning the whole history.
        return {**DECLARATION, "series": _series([c["close"] for c in sdk.candles], params)}
    return DECLARATION

Note the use of a constant value ("value": 30) instead of a series target. There is no on_bar_strategy, but the engine still needs two things you must supply: an accepted entrypoint (main, above — a lone DECLARATION is rejected at init with the Strict Mode ProtocolError) and the rsi series the conditions cross (computed in _build_chart / the runtime branch). And the conditions only fire when you opt in with params["runtime_declarative_fallback"] = True; otherwise the strategy plots but produces 0 trades.

Known limitations

  • No composite logic (AND/OR/NOT). One condition = one comparison.
  • No condition histogram. It is not possible to express "fire if the condition is true in 3 of the last 5 bars".
  • No cooldown. If two conditions fire on two consecutive bars, the engine tries to execute both (the second is usually rejected by Max Positions = 1).
  • No filters. It is not possible to express "only fire during business hours" (except through the global trading_hours of the DECLARATION).

For these cases, use imperative mode.

Next steps