Troubleshooting
Most common problems in backtesting, with root cause and fix. Ordered by frequency of occurrence.
"0 trades"
The backtest completes, but the trade list is empty.
Cause 1 - entry_conditions and on_bar_strategy together
The most frequent case. If both declarative entry_conditions and an imperative on_bar_strategy exist, the runtime ignores the declarative conditions by default. The imperative handler runs first; declarative entry_conditions are only evaluated as an opt-in fallback, and only when no imperative signals were emitted AND params["runtime_declarative_fallback"] = True. The usual cause of zero trades is intending one mode but emitting nothing in it: choose a single mode.
# Incorrect
DECLARATION = {
"entry_conditions": [{"action": "buy_to_open", "enabled": True, ...}],
...
}
def on_bar_strategy(sdk, params):
sdk.buy(...) # runs (imperative path executes first; declarative is opt-in only)Fix: choose one mode and remove the other. Details in when to use declarative mode.
Cause 2 - Insufficient data for warmup
The strategy requires N candles, but the backtest has fewer than N. The script returns early on every bar:
if len(sdk.candles) < 200: # 200-bar warmup
returnFix: reduce the minimum period or extend the backtest interval. For SMA 200, use at least 500 candles to obtain effective signals.
Cause 3 - Entry condition never true
The signal does not fire in the period. Possible reasons:
- Thresholds too restrictive (e.g., RSI < 10, extremely rare).
- Trend filter never aligned with the signal.
- Bug in the condition (wrong operator, different series name).
Fix: add print() temporarily:
if sdk.position == 0:
if rsi < oversold:
print(f"SIGNAL fires: rsi={rsi}")
sdk.buy(...)If prints appear but trades do not, the issue is in sdk.position (position already open) or rejection due to balance.
Cause 4 - sdk.buy() without action=
Raises ProtocolError and halts execution. On the panel, the error is displayed. If the script uses only print() instead of sdk.buy(), the error does not occur.
Fix: see canonical actions.
"Only 1 trade"
One entry and one forced exit at the end of the period.
Cause - missing exit condition
The position is opened but there is no logic to close it. The backtest keeps the position open until the last candle, closing on termination.
# Incorrect: only enters, never exits
if sdk.position == 0 and crossed_up:
sdk.buy(action="buy_to_open", qty=1, order_type="market")
# Missing: elif sdk.position > 0 and crossed_down: ...Fix: add elif sdk.position > 0 and <condition>: sdk.sell(action="sell_to_close", ...).
"Positions accumulating"
The log shows several consecutive buy_to_open orders when one per signal is expected.
Cause - missing if sdk.position == 0: before entering
On every bar in which the condition is true, the script attempts to open another position. By default the backtest runs with Max Positions = 1 (ProfitChart behavior), so the extra buy_to_open orders are silently rejected — but they still pollute the log. If you raise Max Positions in the backtest settings, the entries actually accumulate (pyramiding). Either way, guard entries with if sdk.position == 0:.
# Incorrect
if fast_ma > slow_ma:
sdk.buy(action="buy_to_open", qty=1, order_type="market")
# Correct
if sdk.position == 0 and fast_ma > slow_ma:
sdk.buy(action="buy_to_open", qty=1, order_type="market")Absurd metrics (Sharpe > 5, PF > 10)
Cause 1 - Look-ahead bias
Use of future data in the current decision. Classic example:
# Incorrect: uses high of the candle still closing
if sdk.candles[-1]["high"] > sdk.candles[-1]["close"]:
sdk.buy(...)The high of the current candle is only known at close, but the code makes the decision as if the value were available during the candle. The backtest accepts this data as legitimate, generating a fantastic result that is not reproducible live.
Fix: use only data from the previous candle (sdk.candles[-2]) to decide at the close of the current one. The current candle may be used for close (decision price).
Cause 2 - Optimistic execution model
Execution mode set to "optimistic": when stop and target are touched on the same candle, the target wins. Live, the stop wins; the mode is unrealistic.
Fix: in backtest settings, use pessimistic mode (default).
Cause 3 - probFillOnLimit = 1.0 on limit orders
With 100% fill on limit, every order that touches the price executes. In a real market, the order is not always ahead in the queue.
Fix: configure probFillOnLimit = 0.6 to simulate a realistic book.
Cause 4 - Zeroed fees
Without fees and slippage, marginally profitable strategies present unrealistic results.
Fix: verify the configuration section. Fees are on by default; if zeroed, restore them.
Cause 5 - Short backtest concentrated in a specific regime
Sharpe 3.0 over 3 months of bull market may be luck. There is no guarantee of survival across a full year with bear and sideways phases.
Fix: test across multiple periods (2021, 2022, 2023). Drastic metric degradation indicates overfitting.
"TimeoutError"
A single bar exceeded the 800ms per-bar budget. The engine tolerates isolated occurrences (cold starts, GC pauses, occasional spikes) — a backtest only aborts when transient failures cross 5% of total bars (and at least 5 bars failed in absolute terms). If the run is finishing successfully but the logs include X/Y bar callbacks failed transiently, you are within tolerance and no action is required. If the run aborts, the most common causes are below.
⚠️ A persistently slow frame is not just a tolerated
TimeoutError. When a bar overruns the per-bar budget, the worker's reader abandons the late response and the request/response protocol can desynchronize — the next read then consumes an out-of-order line and the backtest dies withProtocolError: Failed to parse persistent strategy output JSON: data did not match any variant of untagged enum StrategyOutput. Unlike aTimeoutError, thisProtocolErroris fatal and is not covered by the 5% tolerance — it aborts the whole run immediately. The only reliable cure is to keep every frame O(1) per bar (incremental indicators), not to rely on the tolerance.
Cause 1 - Heavy loop over all candles
# Incorrect: O(n^2), redoing it on every bar
for i in range(len(sdk.candles)):
for j in range(len(sdk.candles)):
...Fix: use only the current point or the last N bars. Do not iterate over the full history.
Cause 2 - pd.DataFrame(sdk.candles) on every bar
Building a DataFrame is costly. On every bar, over 10k backtest candles, the cumulative cost exceeds the timeout.
Fix: use a pure-Python list comprehension:
closes = [c["close"] for c in sdk.candles]Cause 3 - Recursive indicators recomputed over the full history
Recomputing EMA/RSI/MACD from scratch over the whole growing sdk.candles on every bar is O(n) per bar → O(n²) over the run. Two native fixes, neither needs an import:
Bounded window (simplest): call the injected
Indicatorglobal over a fixedsdk.candles[-N:]slice. A window comfortably larger than the period keeps each bar O(1) in history length and converges to the full-history value.pythonLOOKBACK = 300 # fixed window >> period → O(1) in history length def on_bar_strategy(sdk, params): period = int(params.get("period", 14)) if len(sdk.candles) < period + 2: return rsi = Indicator.rsi(sdk.candles[-LOOKBACK:], period)[-1] # no import # ... trading logicIncremental accumulator: keep the recursive state in
sdk.stateand update from only the newest close — bit-exact and O(1) (see persistent state).pythondef on_bar_strategy(sdk, params): period = int(params.get("period", 20)) k = 2 / (period + 1) close = sdk.candles[-1]["close"] ema = sdk.state["ema"] # 0.0 on first bar ema = close if ema == 0.0 else ema + k * (close - ema) # O(1) sdk.state["ema"] = ema
"SecurityError"
Code rejected by the engine before running.
Cause 1 - Forbidden import
import os # SecurityError: Forbidden pattern detected: os module importFix: use only numpy, pandas, math, json, datetime, pandas_ta, talib. Details in sandbox limits.
Cause 2 - Blocked builtin
open("file.txt", "w") # SecurityError: Forbidden pattern detected: file open
eval("1+1") # SecurityError: Forbidden pattern detected: eval callFix: there is no I/O or dynamic execution. Rewrite the strategy without these calls.
Cause 3 - Dunder attribute
x.__internals__ # SecurityError: Dunder attribute forbidden: __internals__Fix: __xxx__ attributes are not available. To check a type, use isinstance().
Cause 4 - Lambda
key = lambda x: x[1] # SecurityError: Lambda functions are forbiddenFix: use def:
def _key(x):
return x[1]"MemoryError"
Exceeded the per-strategy memory ceiling. Trading logic rarely hits this organically — when it does, the cause is almost always an unbounded growing collection in sdk.state.
Cause - Accumulating lists without a limit
sdk.state["all_closes"] = sdk.state.get("all_closes", []) + [c["close"] for c in sdk.candles]
# Grows quadratically throughout the backtestFix: bound the size:
buf = sdk.state.setdefault("closes", [])
buf.append(sdk.candles[-1]["close"])
if len(buf) > 500:
buf[:] = buf[-500:] # del is forbidden in the sandbox; use slice assignment"insufficient capital"
Order rejected because sdk.cash is less than the cost.
Cause 1 - qty=1 on crypto spot with a small balance
If sdk.cash = 100 USDT and BTC = 50000, qty=1 BTC costs 50000 and is rejected.
Fix: use size_pct or compute qty proportionally:
close = sdk.candles[-1]["close"]
qty = (sdk.cash * 0.25) / close # 25% of cash
sdk.buy(action="buy_to_open", qty=qty, order_type="market")Cause 2 - Very low initial balance
If the initial balance is BRL 1,000 and the asset is WIN (BRL 5,000/contract), the first trade is rejected.
Fix: increase the balance or use a compatible asset.
"ProtocolError: Strict Mode"
The script does not define a valid entrypoint.
Fix: define at least one accepted entrypoint at the root level. The runtime accepts, in precedence order: main(df=None, sdk=None, params={}); on_bar(sdk); one of the event hooks on_trade_tick / on_quote_tick / on_book_update; or a Strategy class (the runtime binds its .on_bar, falling back to .next). Strict Mode only rejects a script when none of these is present.
"ProtocolError: Failed to parse persistent strategy output JSON: data did not match any variant of untagged enum StrategyOutput"
This is a different, fatal ProtocolError — not the Strict Mode entrypoint error above, and not a tolerated timeout. It almost always appears after a run has been going for a while (as candle history grows) and then aborts the whole backtest at once.
ProtocolError: Failed to parse persistent strategy output JSON: data did not match any variant of untagged enum StrategyOutput — The persistent request/response protocol desynchronized, almost always because a frame overran the per-bar time budget (typically an O(n²) indicator recomputed over the full sdk.candles history on every bar). Unlike TimeoutError, this error is fatal: it is not per-bar-recoverable and aborts the backtest immediately, bypassing the 5% tolerance. Fix: make every frame O(1) — compute over a bounded sdk.candles[-N:] window with the injected Indicator global (no import), or keep an incremental accumulator in sdk.state; never rescan the whole sdk.candles each bar.
See "Cause 3 - Recursive indicators recomputed over the full history" under the "TimeoutError" section above for the bounded-window and accumulator patterns, and persistent state for caching accumulators across bars.
Plot does not appear on the chart
The backtest runs, but the indicator line does not appear.
Cause 1 - Plot source different from the key in series
"plots": [{"name": "sma", "source": "sma_fast", ...}] # source = "sma_fast"
"series": {"sma": [...]} # key = "sma" - divergentFix: keep source equal to the key in series.
Cause 2 - Series array of different length than candles
The frontend discards a misaligned series.
Fix: ensure len(series["sma"]) == len(df). Use None for warmup.
Cause 3 - Missing the df= branch
def main(df=None, sdk=None, params={}):
if sdk is not None:
return on_bar_strategy(sdk, params)
return DECLARATION
# Missing: if df is not None: return _build_chart(df, params)Without the df= branch, the frontend does not receive the series.
Next steps
- Reading the results - understanding the panel.
- Performance metrics - when atypical numbers are a red flag.
- Solid entry/exit patterns - checklist to avoid these problems.