Skip to content
Single .md

When to use entry/exit conditions

TessTrade offers two ways of writing the logic of a strategy. They can technically coexist, but on_bar_strategy always runs and takes precedence -- entry_conditions only act as an opt-in declarative fallback. For clarity, pick one approach per script:

  1. Imperative mode (on_bar_strategy) -- you write Python code that decides when to buy/sell.
  2. Declarative mode (entry_conditions / exit_conditions) -- you describe the conditions in JSON and the engine executes them.

Quick decision

SituationUse
Logic involves more than 2 indicatorsImperative
Requires persistent state (sdk.state)Imperative
Requires cooldown or temporal filtersImperative
Manual trailing stopImperative
Strategy is "when A crosses B, buy"Declarative
Non-programmer users must edit the rulesDeclarative
Simple crossing of two seriesDeclarative
Sharing a strategy through UI templatesDeclarative

When in doubt: imperative. It is more expressive and covers everything the declarative mode offers.

⚠️ Imperative handlers run under a strict per-bar time budget. on_bar_strategy is called once per closed candle with a default budget of roughly 800 ms per frame, and sdk.candles is a single buffer that grows across the whole backtest. So recomputing an indicator over the full history every bar — e.g. rsi([c["close"] for c in sdk.candles], 14) — is O(n) per bar and O(n²) over the run, and a frame that overruns the budget can desynchronize the request/response protocol and abort the entire backtest with a fatal ProtocolError (not a tolerated TimeoutError). Compute indicators over a bounded window instead: call the pre-injected Indicator global (no import) over a fixed sdk.candles[-N:] slice — a window comfortably larger than the period is O(1) in history length and converges to the full-history value — or keep an incremental accumulator in sdk.state — never re-scan all of sdk.candles each frame. (tesstrade_indicators is an internal native module and is not importable from the strategy editor; the injected Indicator exposes the same math with no import.)

The critical rule

on_bar_strategy is NOT ignored when entry_conditions are present. On each closed candle the engine runs on_bar_strategy first (via main(df=None, sdk=...)) and uses its signals. The declarative entry_conditions / exit_conditions are only evaluated as a fallback, and only when (a) on_bar_strategy emitted no signals and (b) you opted in with params['runtime_declarative_fallback'] = True. By default the declarative runtime path is disabled, so on_bar_strategy is what actually executes -- the engine does not silently switch to "entirely declarative mode". Mixing both is still discouraged for clarity.

When both are present, on_bar_strategy runs and its signals are used. If on_bar_strategy emits nothing and you have not set params['runtime_declarative_fallback'] = True, the engine prints a warning to stdout and the entry_conditions are not evaluated, so you get "0 trades". The cause is the disabled-by-default declarative fallback, not a silent mode switch.

python
# Anti-pattern: has entry_conditions and on_bar_strategy
DECLARATION = {
    "type": "strategy",
    "entry_conditions": [
        {"source": "fast", "operator": "crosses_above", "target": "slow",
         "action": "buy_to_open", "enabled": True},
    ],
    # ...
}

def on_bar_strategy(sdk, params):   # called by your main() dispatcher, below
    # This DOES run: the imperative handler executes first every bar and its
    # signals win. The entry_conditions above stay dormant (opt-in fallback,
    # off by default) — so the real problem here is redundancy, not that one
    # side is silently "in declarative mode" and ignored.
    sdk.buy(action="buy_to_open", qty=1, order_type="market")

How to keep it clear

Mixing the two does not break anything — on_bar_strategy still runs — but a script is easier to reason about when it commits to one approach.

For manual logic, remove entry_conditions and exit_conditions so only the imperative path remains. Remember on_bar_strategy is a convention name your own main() dispatches to — the engine's accepted entrypoint is main (or on_bar / an event hook / a Strategy class); it does not scan for on_bar_strategy, so the dispatcher is required or the file is rejected at init with the Strict Mode ProtocolError:

python
# No import: `Indicator` is a pre-injected global (numpy/pandas/pandas_ta/talib/
# math/json/datetime are the only allowlisted imports; `tesstrade_indicators`
# is an internal native module and is NOT importable here).

FAST, SLOW = 9, 21

DECLARATION = {
    "type": "strategy",
    "inputs": [...],
    "plots": [...],
    # no entry_conditions, no exit_conditions
}

def on_bar_strategy(sdk, params):
    if len(sdk.candles) < SLOW:
        return
    # Bounded slices → O(1) in history length; SMA needs only `period` bars.
    fast = Indicator.sma(sdk.candles[-FAST:], FAST)[-1]     # native, no import
    slow = Indicator.sma(sdk.candles[-SLOW:], SLOW)[-1]
    if fast is not None and slow is not None and fast > slow:
        sdk.buy(action="buy_to_open", qty=1, order_type="market")

def main(df=None, sdk=None, params={}):   # the accepted entrypoint
    params = params or {}
    if sdk is not None:
        return on_bar_strategy(sdk, params)
    return DECLARATION

For declarative mode, remove the on_bar_strategy and keep the dispatcher below. Note this shape renders the plots and exposes the conditions, but to make the conditions actually trade in a backtest you must opt in with params["runtime_declarative_fallback"] = True and return the computed series on the runtime branch too (see the complete example below — a bare DECLARATION carries no series and fires nothing):

python
def main(df=None, sdk=None, params={}):
    params = params or {}
    if df is not None:
        return _build_chart(df, params)
    return DECLARATION

Declarative mode in practice

The engine evaluates conditions against the series returned in the df= branch. You must declare the plots and provide the series; the engine crosses the values and fires the actions.

Complete example: declarative SMA crossover

python
DECLARATION = {
    "type": "strategy",
    "inputs": [
        {"name": "fast_period", "type": "int", "default": 9, "min": 1, "max": 100},
        {"name": "slow_period", "type": "int", "default": 21, "min": 2, "max": 200},
    ],
    "plots": [
        {"name": "ma_fast", "source": "ma_fast", "type": "line", "color": "#22D3EE"},
        {"name": "ma_slow", "source": "ma_slow", "type": "line", "color": "#F59E0B"},
    ],
    "entry_conditions": [
        {
            "name": "Buy",
            "description": "Fast crosses above Slow",
            "source": "ma_fast",
            "operator": "crosses_above",
            "target": "ma_slow",
            "action": "buy_to_open",
            "enabled": True,
        },
        {
            "name": "Short Sell",
            "description": "Fast crosses below Slow",
            "source": "ma_fast",
            "operator": "crosses_below",
            "target": "ma_slow",
            "action": "sell_short_to_open",
            "enabled": True,
        },
    ],
    "exit_conditions": [
        {
            "name": "Long Exit",
            "source": "ma_fast",
            "operator": "crosses_below",
            "target": "ma_slow",
            "action": "sell_to_close",
            "enabled": True,
        },
        {
            "name": "Short Cover",
            "source": "ma_fast",
            "operator": "crosses_above",
            "target": "ma_slow",
            "action": "buy_to_cover",
            "enabled": True,
        },
    ],
}


def _sma_series(values, period):
    out = []
    for i in range(len(values)):
        if i + 1 < period:
            out.append(None)
        else:
            out.append(sum(values[i - period + 1:i + 1]) / period)
    return out


def _series(closes, params):
    fast = int((params or {}).get("fast_period", 9))
    slow = int((params or {}).get("slow_period", 21))
    return {
        "ma_fast": _sma_series(closes, fast),
        "ma_slow": _sma_series(closes, slow),
    }


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 series
    if sdk is not None:
        # Declarative fallback path: the evaluator crosses the series that main
        # returns HERE, so the runtime branch must return them too — a bare
        # DECLARATION carries no series and would fire nothing.
        # WARNING: rebuilding the SMA series over the full sdk.candles every bar
        # is O(n) per bar / O(n^2) over the backtest and can overrun the ~800 ms
        # per-bar budget. For anything heavier than two SMAs, accumulate
        # incrementally in sdk.state or switch to an imperative on_bar_strategy.
        return {**DECLARATION, "series": _series([c["close"] for c in sdk.candles], params)}
    return DECLARATION

Note: there is no on_bar_strategy. All trading logic lives in entry_conditions and exit_conditions. For the conditions to actually fire in a backtest you MUST opt in by setting params['runtime_declarative_fallback'] = True (for example via an input default), and the runtime (df=None, sdk=...) branch must return the same series map — the declarative evaluator crosses the series that main returns on the per-bar call, so a bare DECLARATION (no series) evaluates nothing and you get 0 trades. Rebuilding those series over the full sdk.candles every bar is O(n²) over a backtest; for anything heavier than two SMAs, accumulate incrementally in sdk.state or move to an imperative on_bar_strategy.

How the engine evaluates

On every closed candle, the engine takes the last two points of each series and applies the operator. For crosses_above(ma_fast, ma_slow):

  • ma_fast[-2] <= ma_slow[-2] (on the previous candle it was below)
  • ma_fast[-1] > ma_slow[-1] (now it is above)

If both conditions hold, the condition fires and the engine emits the action.

Advantages and limitations

Advantages of declarative mode

  • Readable. A non-programmer trader reads and understands it.
  • Shareable. A JSON/YAML template can be exported, cloned, and versioned.
  • Zero state bugs. No sdk.state to forget to reset.

Limitations

  • No state. sdk.state does not exist in declarative mode. Cooldown, counters, and manual trailing are not possible.
  • No composite logic. It is not possible to express "enter if (A crosses B) AND (RSI < 30)" directly. Only one condition per entry.
  • No dynamism. Thresholds are fixed; there is no adaptation to the market regime.
  • No time-based exit. "Exit after 4 hours" cannot be expressed.

Rule of thumb: if the strategy goes beyond "when X crosses Y", use on_bar_strategy.

How the frontend uses both modes

The "Strategies" UI in chart trading offers a visual builder to assemble entry_conditions without writing Python. These visual templates generate declarative DECLARATIONs that the engine executes.

When exporting a strategy built in the UI, the generated Python contains only the DECLARATION and _build_chart, without on_bar_strategy. It is purely declarative.

To customize beyond the UI, you must transition to imperative mode: copy the generated Python, remove entry_conditions, and write on_bar_strategy.

Next steps