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:
- Imperative mode (
on_bar_strategy) -- you write Python code that decides when to buy/sell. - Declarative mode (
entry_conditions/exit_conditions) -- you describe the conditions in JSON and the engine executes them.
Quick decision
| Situation | Use |
|---|---|
| Logic involves more than 2 indicators | Imperative |
Requires persistent state (sdk.state) | Imperative |
| Requires cooldown or temporal filters | Imperative |
| Manual trailing stop | Imperative |
| Strategy is "when A crosses B, buy" | Declarative |
| Non-programmer users must edit the rules | Declarative |
| Simple crossing of two series | Declarative |
| Sharing a strategy through UI templates | Declarative |
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_strategyis called once per closed candle with a default budget of roughly 800 ms per frame, andsdk.candlesis 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 fatalProtocolError(not a toleratedTimeoutError). Compute indicators over a bounded window instead: call the pre-injectedIndicatorglobal (no import) over a fixedsdk.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 insdk.state— never re-scan all ofsdk.candleseach frame. (tesstrade_indicatorsis an internal native module and is not importable from the strategy editor; the injectedIndicatorexposes 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.
# 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:
# 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 DECLARATIONFor 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):
def main(df=None, sdk=None, params={}):
params = params or {}
if df is not None:
return _build_chart(df, params)
return DECLARATIONDeclarative 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
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 DECLARATIONNote: 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.stateto forget to reset.
Limitations
- No state.
sdk.statedoes 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
- Supported operators -- complete table of declarative operators.
- DECLARATION shape -- fields of
entry_conditionsandexit_conditions. - Solid patterns -- how to write robust imperative code.