SMA Crossover
Classic strategy based on two simple moving averages, one fast and one slow. When the fast crosses above the slow, it opens long. When it crosses below, it closes long and opens short. It reverses the position when it crosses back.
Serves as a starting point for understanding the dispatcher, the DECLARATION, and the order flow. The implementation fits in fewer than 100 lines.
The plotted moving averages use Indicator.sma — the pre-injected native indicator kernel, the same math the chart's built-in SMA renders with, available with no import — so the lines drawn here match the built-in indicator exactly.
# No import needed: `Indicator` is a pre-injected global (native indicator kernel).
# (1) Identity + (2) Parameters + (3) Style/colors — all at the top, in the DECLARATION
DECLARATION = {
"type": "strategy",
"inputs": [
{
"name": "fast_period",
"label": "Fast Moving Average",
"type": "int",
"default": 9,
"min": 1,
"max": 100,
"step": 1,
},
{
"name": "slow_period",
"label": "Slow Moving Average",
"type": "int",
"default": 21,
"min": 2,
"max": 200,
"step": 1,
},
],
"plots": [
{
"name": "ma_fast",
"title": "Fast SMA",
"source": "ma_fast",
"type": "line",
"color": "#22D3EE",
"width": 2,
},
{
"name": "ma_slow",
"title": "Slow SMA",
"source": "ma_slow",
"type": "line",
"color": "#F59E0B",
"width": 2,
},
],
"pane": "overlay",
"scale": "none",
"levels": [],
}
def _build_chart(df, params):
# In the df= branch, spread DECLARATION so pane/scale/plots travel with the series.
# See: contract/dispatcher-main.md
fast = int((params or {}).get("fast_period", 9))
slow = int((params or {}).get("slow_period", 21))
# (4) Math — Indicator.sma is the chart's own native kernel (no import), so these
# match the built-in SMA exactly. It accepts the DataFrame and reads source='close'.
# (5) Output — Indicator.sma returns one value per candle, None during warmup.
return {
**DECLARATION,
"series": {
"ma_fast": Indicator.sma(df, fast),
"ma_slow": Indicator.sma(df, slow),
},
}
def main(df=None, sdk=None, params={}):
# Strict Mode requires an accepted entrypoint (main / on_bar / on_*_tick /
# a Strategy class). This 3-branch dispatcher wires the per-bar backtest,
# the chart (study) call, and the declaration probe. on_bar_strategy on its
# own is NOT an entrypoint — without this main() the runner rejects the script.
params = params or {}
if sdk is not None:
return on_bar_strategy(sdk, params)
if df is not None:
return _build_chart(df, params)
return DECLARATION
def on_bar_strategy(sdk, params):
fast_period = int(params.get("fast_period", 9))
slow_period = int(params.get("slow_period", 21))
if len(sdk.candles) < slow_period + 1:
return
# An SMA needs only `period` bars, so slice exactly that many — O(1) in
# history length. Indicator.sma (native, no import) reads 'close' from the
# candle dicts; take the last value.
fast = Indicator.sma(sdk.candles[-(fast_period + 1):], fast_period)[-1]
slow = Indicator.sma(sdk.candles[-(slow_period + 1):], slow_period)[-1]
if fast is None or slow is None:
return
if sdk.position == 0:
if fast > slow:
sdk.buy(action="buy_to_open", qty=1, order_type="market")
elif fast < slow:
sdk.sell(action="sell_short_to_open", qty=1, order_type="market")
elif sdk.position > 0 and fast < slow:
sdk.sell(action="sell_to_close", qty=abs(sdk.position), order_type="market")
elif sdk.position < 0 and fast > slow:
sdk.buy(action="buy_to_cover", qty=abs(sdk.position), order_type="market")Keep every bar O(1). The
on_bar_strategybody slices exactlyperiod + 1bars into eachIndicator.smacall, so the work per bar is bounded and does not grow with history — never pass the wholesdk.candleshistory. A full-history recompute (Indicator.sma([c["close"] for c in sdk.candles], period)) is O(n) per bar and O(n²) over the backtest; as history grows, a late frame overruns the ~800 ms per-bar budget, which can desync the request/response protocol into a fatalProtocolErrorthat aborts the whole run — see Compute indicators incrementally.Indicatoris a pre-injected global; there is nothing to import.
When to use
- Markets with strong trend. The crossover captures the inflection.
- Medium timeframes (15m, 1h, 4h). On short timeframes, noise triggers many false signals.
What to expect
- Long but rare trades. Typically 1 to 4 per week on 1h crypto.
- Drawdown in sideways markets. The crossover keeps oscillating and loses to slippage and fees.