Strategies
Strategies
Strategies are the decision-making logic that determines when to buy, sell, or hold positions during a backtest. Each strategy receives market data, portfolio state, and pre-computed indicator values, and returns a list of orders to execute. Backtide provides a set of built-in strategies as well as a framework for creating custom strategies.
How they work
Every strategy inherits from BaseStrategy and implements a evaluate method
that receives data, state, and indicators, and returns a list of orders:
- Single-asset strategies operate on one instrument at a time, making buy/sell decisions based on that instrument's data and indicators.
- Portfolio-rotation strategies operate across multiple instruments, periodically ranking and rotating the portfolio into the top performers.
When running a backtest, the strategy's evaluate method is called on every
bar. It receives:
data— OHLCV data available up to the current bar.portfolio— the current portfolio (cash, positions and open orders).state— the current state (timestamp, bar index, warmup flag, etc...).indicators— pre-computed indicator values (only up to the current bar).
The method should return a list of orders to execute on the current bar.
Orders
An order is a trade instruction. Each order is an Order object with a symbol,
a signed quantity, an order type, and — depending on the type — one or two price fields.
from backtide.backtest import Order
# Buy 50 shares of AAPL at market price
Order(symbol="AAPL", order_type="market", quantity=50)
# Sell 20 shares with a limit at $185
Order(symbol="AAPL", order_type="limit", quantity=-20, price=185.0)
Order types
The order_type field determines when and how the order is filled. You can pass
an OrderType instance or a string. Strings are parsed flexibly: PascalCase
("StopLoss") and snake_case ("stop_loss") are both accepted, case-insensitively.
Only order types listed in ExchangeExpConfig.allowed_order_types
are accepted; others are rejected immediately.
| Type | Fills when… | Price fields |
|---|---|---|
Market |
Immediately at the next bar's open (or current close if trade_on_close is enabled). |
— |
Limit |
The market reaches the limit price or better. A buy limit fills at-or-below price; a sell limit fills at-or-above price. |
price = limit |
StopLoss |
The market moves against you past the stop. A sell stop triggers when the price falls to price; a buy stop triggers on a rise. Once triggered, fills like a market order. |
price = stop |
TakeProfit |
The market moves in your favour past the target. Execution semantics are identical to a limit order. | price = target |
StopLossLimit |
Same trigger as StopLoss, but once triggered the order converts to a limit resting at limit_price instead of filling at market. |
price = stop, limit_price = limit |
TakeProfitLimit |
Same trigger as TakeProfit, but converts to a limit at limit_price. |
price = target, limit_price = limit |
TrailingStop |
A stop that follows the market. The engine tracks the running high (for sells) or running low (for buys). The stop triggers when the price reverses by price units from the extreme. Fills like a market order. |
price = trail amount |
TrailingStopLimit |
Same as TrailingStop, but converts to a limit at limit_price instead of filling at market. |
price = trail amount, limit_price = limit |
SettlePosition |
Closes the entire open position in the symbol at a market price. Quantity is computed by the engine. | — |
Cancel |
Cancels a pending order. Set id to the ID of the order to cancel. Other fields (symbol, quantity, price, limit_price) are ignored. |
— |
Note
Limit-style orders are protected against slippage: a buy limit will never fill above the limit price, and a sell limit will never fill below it, even after the configured slippage percentage is applied.
Warning
Every pending order must have a unique id. If you submit an order whose
id matches one already in the order book, the duplicate is immediately
rejected. When you omit the id parameter, the engine auto-generates a
unique one.
Examples
def evaluate(self, data, portfolio, state, indicators):
orders = []
for symbol, df in data.items():
close = df["close"].iloc[-1]
qty = portfolio.positions.get(symbol, 0)
if qty == 0:
# Enter long at market
orders.append(Order(
symbol=symbol,
order_type="market",
quantity=100,
))
# Attach a stop-loss 5% below entry
orders.append(Order(
symbol=symbol,
order_type="stop_loss",
quantity=-100,
price=close * 0.95,
))
# Attach a take-profit 10% above entry
orders.append(Order(
symbol=symbol,
order_type="take_profit",
quantity=-100,
price=close * 1.10,
))
return orders
# Trail the high by $2. If the stock rises from 100 to 120 and then
# drops back to 118, the trailing stop triggers at 118.
Order(
symbol="AAPL",
order_type="trailing_stop",
quantity=-100,
price=2.0, # Trail amount in price units
)
Cancelling orders
Pending orders (limit, stop, trailing) stay in the order book until they are
filled, canceled or expire at the end of the simulation. You can inspect
currently open orders via portfolio.orders — each entry is an Order
object whose id attribute uniquely identifies it.
To cancel a specific order, submit a Cancel whose id matches the
target:
# Place a limit order with a known ID
orders.append(Order(
id="my-limit",
symbol="AAPL",
order_type="limit",
quantity=50,
price=150.0,
))
# On a later bar, cancel it
orders.append(Order(id="my-limit", order_type="cancel"))
Info
For Cancel orders, only the id field matters. You can omit symbol,
quantity, price and limit_price since the engine ignores them.
If you didn't assign a custom id when submitting the order, the engine
auto-generates one. You can retrieve it from the portfolio:
from backtide.backtest import OrderType
# Cancel all pending stop-loss orders for AAPL
for pending in portfolio.orders:
if pending.symbol == "AAPL" and pending.order_type == OrderType.StopLoss:
orders.append(Order(id=pending.id, order_type="cancel"))
Alternatively, enable EngineExpConfig.exclusive_orders to
have the engine automatically cancel all pending orders whenever a new order is
submitted. This is convenient for strategies that should only have one active
order at a time.
Sizing
Instead of computing a numeric quantity yourself, you can pass a sizer as
quantity. The engine resolves the sizer into a concrete number of units just
before the order is queued.
from backtide.sizers import EqualWeight, FixedFractional
# Allocate an equal slice of equity to this position
Order(symbol="AAPL", order_type="market", quantity=EqualWeight())
# Risk 2% of equity per trade
Order(symbol="AAPL", order_type="market", quantity=FixedFractional(0.02))
See Sizers for the full list of built-in sizers and how to create custom ones.
Auto-injected indicators
Most built-in strategies depend on a handful of indicators (e.g., SMA Crossover needs two SMAs, BB Mean Reversion needs Bollinger Bands, etc...). To save you from having to add those manually on every experiment, the engine auto-injects them for you.
Auto-injected indicators behave exactly like user-selected ones — they are
computed once over the full dataset before the simulation starts and are then
sliced per bar for the strategy. They are de-duplicated across strategies, so
two strategies asking for the same SMA(20) only compute it once.
Custom strategies
You can create your own strategies by subclassing BaseStrategy. Custom
strategies can be written directly in the application's code
editor or uploaded as .py files.
Example
from backtide.backtest import Order
from backtide.sizers import EqualWeight
from backtide.strategies import BaseStrategy
class InsideBarBreakout(BaseStrategy):
"""Long-only inside-bar breakout strategy.
Entry:
- Previous bar is an inside bar relative to the bar before it.
- Current close breaks above the inside bar high.
Exit:
- Current close falls below the inside bar low.
"""
def __init__(self, cash_fraction=0.95):
self.cash_fraction = cash_fraction
def evaluate(self, data, portfolio, state, indicators):
orders = []
entry_candidates = []
# First pass: determine exits and potential entries.
for symbol, df in data.items():
# Need at least 3 bars:
# bar[-3] = "mother bar", bar[-2] = "inside bar", bar[-1] = current
if len(df) < 3:
continue
mother = df.iloc[-3]
inside = df.iloc[-2]
current = df.iloc[-1]
current_qty = portfolio.positions.get(symbol, 0)
is_inside_bar = inside["high"] < mother["high"] and inside["low"] > mother["low"]
breakout_up = current["close"] > inside["high"]
breakdown_down = current["close"] < inside["low"]
# Exit existing long on downside break.
if current_qty > 0 and breakdown_down:
orders.append(Order(symbol=symbol, order_type="market", quantity=-current_qty))
continue
# Track new long entries.
if current_qty <= 0 and is_inside_bar and breakout_up:
entry_candidates.append(symbol)
# Second pass: hand sizing off to the built-in EqualWeight sizer
# (scaled down by `cash_fraction` so the strategy keeps a cash buffer).
if entry_candidates:
sizer = EqualWeight(n_positions=int(len(entry_candidates) / self.cash_fraction))
for symbol in entry_candidates:
orders.append(Order(symbol=symbol, order_type="market", quantity=sizer))
return orders
InsideBarBreakout()
Custom strategies can either compute a numeric quantity for every order or attach
a sizer directly to an Order by passing it as quantity. Attached sizers
are resolved by the engine just before the order is queued. The engine converts
current portfolio equity into the order instrument's quote currency.
Performance
Backtide is fast because the hot path is deliberately kept out of Python. The experiment engine, order matching, portfolio accounting, currency conversion, metrics and built-in strategies are implemented in Rust.
A custom strategy's performance is mostly determined by what happens inside
evaluate(), because that method is called once per bar. Recommended patterns
are:
| Do | Avoid |
|---|---|
Declare expensive rolling features in required_indicators(). |
Recomputing SMA/RSI/ATR/rolling statistics inside evaluate(). |
| Keep state on the strategy object for incremental logic. | Rebuilding large temporary lists, dicts or dataframes every bar. |
| Use built-in indicators and built-in strategies when they match your idea. | Reimplementing existing Rust-backed functionality in Python. |
| Vectorize heavy array calculations with NumPy, Polars or pandas outside the hot loop. | Python for loops over long histories inside evaluate(). |
Use numba.njit for expensive custom numeric kernels, and compile them outside evaluate(). |
Decorating/compiling functions dynamically inside evaluate(). |
| Return only the orders you actually want to place. | Returning duplicate orders every bar when a position/order already exists. |
Tip
If a custom strategy is still slow, profile the evaluate() method first.
In most cases, the fix is to move historical calculations into an indicator,
replace Python loops with vectorized operations, or precompile numeric
helpers with Numba.
Example
The strategy below computes a custom z-score momentum signal that would be
slow in a plain Python loop. The heavy lifting is offloaded to a @njit
compiled helper that is defined and compiled once at import time, outside
evaluate(). Make sure to have numba installed in your environment to use
this example.
import numpy as np
from numba import njit
from backtide.backtest import Order
from backtide.sizers import FixedFractional
from backtide.strategies import BaseStrategy
@njit
def zscore_momentum(closes: np.ndarray, lookback: int, threshold: float):
"""Return 1 (buy), -1 (sell) or 0 (hold) based on z-score momentum.
The z-score measures how far the latest close deviates from the
rolling mean of the last `lookback` closes, expressed in standard
deviations. A reading above `+threshold` suggests unusual upward
momentum (buy signal); below `-threshold` suggests the opposite.
Parameters
----------
closes : np.ndarray
1-D float64 array of close prices up to the current bar.
lookback : int
Rolling window length for mean and standard deviation.
threshold : float
Number of standard deviations required to trigger a signal.
Returns
-------
int
1 for buy, -1 for sell, 0 for hold.
"""
n = closes.shape[0]
if n < lookback:
return 0
window = closes[-lookback:]
total = 0.0
for i in range(lookback):
total += window[i]
mean = total / lookback
var = 0.0
for i in range(lookback):
diff = window[i] - mean
var += diff * diff
std = (var / lookback) ** 0.5
if std == 0.0:
return 0
zscore = (closes[-1] - mean) / std
if zscore > threshold:
return 1
elif zscore < -threshold:
return -1
return 0
class ZScoreMomentum(BaseStrategy):
"""Long/flat strategy driven by z-score momentum.
Uses a Numba-compiled kernel to compute a rolling z-score of close
prices. When the z-score exceeds the upper threshold, the strategy
goes long with a fixed fraction of available equity. When it drops
below the lower threshold, existing positions are closed.
"""
def __init__(self, lookback=20, threshold=1.5, cash_fraction=0.95):
self.lookback = lookback
self.threshold = threshold
self.sizer = FixedFractional(fraction=cash_fraction)
def evaluate(self, data, portfolio, state, indicators):
orders = []
for symbol, df in data.items():
closes = df["close"].to_numpy(dtype=np.float64)
signal = zscore_momentum(closes, self.lookback, self.threshold)
current_qty = portfolio.positions.get(symbol, 0)
if signal == 1 and current_qty <= 0:
orders.append(Order(symbol=symbol, order_type="market", quantity=self.sizer))
elif signal == -1 and current_qty > 0:
orders.append(Order(symbol=symbol, order_type="market", quantity=-current_qty))
return orders
ZScoreMomentum()
The first call to zscore_momentum triggers Numba's JIT compilation (a few
hundred milliseconds). Every subsequent call runs at machine-code speed, often
10–100x faster than the equivalent pure-Python loop.
Built-in strategies
All built-in strategies are implemented in Rust and exposed to Python. They are divided into single-asset strategies (operating on one instrument) and portfolio-rotation strategies (ranking and rotating across multiple instruments). See the API reference for full details on each strategy's parameters, attributes, and logic.
Position sizing
Backtide uses sizers to turn a trading signal into an order quantity:
- Signal-following strategies size buys with
FixedNotional: the strategy computes a target cash allocation for the symbol, then converts that notional into units at the latest known close. Sells useFixedQuantityto close the current position. - Equal-weight entries and rotation strategies use
EqualWeight: selected symbols receive an equal slice of current equity/cash. Rotation strategies liquidate symbols that leave the selected set and rebalance into the current winners. BuyAndHoldenters each symbol once, as soon as that symbol has data, and does not resize afterward. If a single benchmark symbol is configured, it only buys that symbol.
Single-asset strategies
| Strategy | Category | Description |
|---|---|---|
AdaptiveRsi |
Momentum | RSI with dynamic period adapting to volatility. |
AlphaRsiPro |
Momentum | Advanced RSI with adaptive levels and trend bias filtering. |
BollingerMeanReversion |
Mean Reversion | Buys at the lower band, sells at the upper band. |
BuyAndHold |
Baseline | Buys on the first day and holds to the end. |
DoubleTop |
Pattern | Buys on breakout after a double-top pattern. |
HybridAlphaRsi |
Momentum | Combines adaptive period, adaptive levels, and trend confirmation. |
Macd |
Trend | Buys on MACD golden cross, sells on death cross. |
Momentum |
Trend | Buys when momentum turns positive, exits on MA filter. |
RiskAverse |
Breakout | Buys low-volatility stocks making new highs on volume. |
Roc |
Momentum | Buys on high Rate of Change, sells on low. |
Rsi |
Momentum | Combines RSI and Bollinger Bands for dual confirmation. |
Rsrs |
Trend | Uses regression of high/low prices for support detection. |
SmaCrossover |
Trend | Golden cross / death cross with two moving averages. |
SmaNaive |
Trend | Buys above MA, sells below. |
TurtleTrading |
Trend | Channel breakout trend-following with equal-weight entries. |
Vcp |
Breakout | Volatility Contraction Pattern breakout. |
Portfolio-rotation strategies
| Strategy | Description |
|---|---|
MultiBollingerRotation |
Rotates into instruments crossing above their upper Bollinger Band. |
RocRotation |
Rotates into the top K instruments by Rate of Change. |
RsrsRotation |
Rotates into instruments with highest RSRS values. |
TripleRsiRotation |
Rotates based on composite long/medium/short RSI scores. |