Overview

Write trading strategies in Python, backtest them against historical data, optimize parameters, and compete on the leaderboard — all from the Playground.

Strategies inherit from StrategyBase and implement lifecycle methods. The engine feeds your strategy one bar at a time, and you respond by placing orders, managing positions, and plotting custom indicators.

Note: Your class must be named MyStrategy and inherit from StrategyBase. The engine validates your code before running.

30+ Indicators

SMA, EMA, RSI, MACD, Bollinger, ATR, and more

7 Order Types

Market, limit, stop, stop-limit, trailing, MOO, MOC

4 Exec Algos

TWAP, VWAP, Iceberg, Percentage of Volume

Quick start

Here's a complete SMA crossover strategy. Click Try in Playground to run it instantly.

Opens in Playground with this code pre-loaded
class MyStrategy(StrategyBase):
    def on_init(self):
        self.params.setdefault('fast', 10)
        self.params.setdefault('slow', 30)

    def _sma(self, closes, period):
        if len(closes) < period:
            return None
        return sum(closes[-period:]) / period

    def on_data(self, bar):
        fast = self.params['fast']
        slow = self.params['slow']
        hist = self.history(bar.symbol, slow + 1)
        if len(hist) < slow + 1:
            return
        closes = [b.close for b in hist]

        fast_now = self._sma(closes, fast)
        slow_now = self._sma(closes, slow)
        fast_prev = self._sma(closes[:-1], fast)
        slow_prev = self._sma(closes[:-1], slow)

        if None in (fast_now, slow_now, fast_prev, slow_prev):
            return

        if self.is_flat(bar.symbol):
            if fast_prev <= slow_prev and fast_now > slow_now:
                qty = max(1, int(self.portfolio.cash * 0.95 / bar.close))
                self.market_order(bar.symbol, qty)
        elif self.is_long(bar.symbol):
            if fast_prev >= slow_prev and fast_now < slow_now:
                self.close_position(bar.symbol)

What's happening:

  1. on_init — sets default parameters for fast (10) and slow (30) moving average periods
  2. on_data — called on every bar with the latest OHLCV data
  3. self.history() — fetches recent bars to compute indicators
  4. self.is_flat() — checks if we have no open position
  5. self.market_order() — places a buy order when the fast MA crosses above the slow
  6. self.close_position() — exits the trade when the fast MA crosses below

Strategy structure

Every strategy follows this pattern:

class MyStrategy(StrategyBase):
    """Your strategy description."""

    def on_init(self):
        # Set parameters, warm-up, initial state
        self.params.setdefault('period', 20)
        self.set_warmup(bars=200)

    def on_data(self, bar):
        # Called each bar — your main trading logic
        hist = self.history(bar.symbol, 20)
        # ... compute indicators, place orders ...

    def on_order_event(self, fill):
        # Optional: react to fills
        pass

    def on_end(self):
        # Optional: cleanup after backtest
        pass
Tip: You can define helper methods on your class (like _sma, _rsi) — only the lifecycle methods are called by the engine.

Lifecycle methods

on_init(self) → None

Called once before the backtest starts. Set default parameters with self.params.setdefault(), configure warm-up with self.set_warmup(), initialize instance variables, and set up scheduled callbacks.

on_data(self, bar: BarData) → None

Called on each new bar (after warm-up completes). This is where your main trading logic goes — compute indicators, check signals, and place orders. This method is abstract and must be implemented.

Required — your strategy won't compile without this method.

on_order_event(self, fill: FillEvent) → None

Called whenever an order fills. Use this for fill-based logic like adjusting trailing stops, logging trades, or placing follow-up orders.

on_end(self) → None

Called when the backtest finishes. Use for cleanup, final calculations, or logging summary statistics.

Data & bar object

Bar properties

Method / PropertyDescription
bar.openOpening price
bar.highHighest price in the bar
bar.lowLowest price in the bar
bar.closeClosing price
bar.volumeVolume traded
bar.symbolTicker symbol (e.g., "AAPL")
bar.timestampBar datetime
bar.bar_indexSequential bar number (0-based)
bar.midComputed: (high + low) / 2
bar.typical_priceComputed: (high + low + close) / 3
bar.rangeComputed: high - low

Fetching history

self.history(symbol=None, length=1) → list[BarData]

Returns the most recent bars for a symbol. If symbol is omitted, uses the primary symbol. History buffer holds up to 500 bars per symbol.

Returns: list[BarData]

# Get last 50 bars
hist = self.history(bar.symbol, 50)
closes = [b.close for b in hist]
highs = [b.high for b in hist]

# Always guard against insufficient data
if len(hist) < 50:
    return

Strategy properties

Method / PropertyDescription
self.portfolioPortfolio object — access cash, equity, positions, P&L
self.timeCurrent simulation datetime
self.bar_indexCurrent bar index (0-based)
self.paramsStrategy parameters dict (set in on_init)
self.storePersistent key-value store that persists between bars
self.is_warming_upTrue if still in warm-up period

Built-in indicators

The engine provides 30+ built-in indicators as global classes. You don't need to import them — they're available directly in your strategy code. Create an instance with parameters, then call it with your data.

Note: Indicators are classes, not methods on self. The pattern is: IndicatorName(params)(data) — instantiate with config, then call with price arrays.
# Single-input indicators: pass a list of closes (or other values)
closes = [b.close for b in self.history(bar.symbol, 50)]
sma_val = SMA(period=20)(closes)         # → float
ema_val = EMA(period=20)(closes)         # → float
rsi_val = RSI(period=14)(closes)         # → float

# Multi-value indicators (return a dict)
macd = MACD(fast=12, slow=26, signal=9)(closes)
# → {"macd": float, "signal": float, "histogram": float}

bb = BollingerBands(period=20, num_std=2.0)(closes)
# → {"upper": float, "middle": float, "lower": float}

# Multi-input indicators: pass high, low, close, volume as keyword args
hist = self.history(bar.symbol, 50)
highs  = [b.high for b in hist]
lows   = [b.low for b in hist]
volumes = [b.volume for b in hist]

stoch = Stochastic(k_period=14, d_period=3)(high=highs, low=lows, close=closes)
# → {"k": float, "d": float}

atr_val = ATR(period=14)(high=highs, low=lows, close=closes)
# → float

mfi_val = MFI(period=14)(high=highs, low=lows, close=closes, volume=volumes)
# → float

# You can also get a full series (useful for crossover detection)
sma_series = SMA(period=20).series(closes)   # → numpy array
Method / PropertyDescription
SMA(period=20)(closes)Simple Moving Average → float. Also has .series(closes) → array
EMA(period=20)(closes)Exponential Moving Average → float. Also has .series(closes) → array
WMA(period=20)(closes)Weighted Moving Average → float
DEMA(period=20)(closes)Double Exponential Moving Average → float
TEMA(period=20)(closes)Triple Exponential Moving Average → float
VWAP()(high=, low=, close=, volume=)Volume-Weighted Average Price → float
BollingerBands(period=20, num_std=2.0)(closes)→ {"upper", "middle", "lower"}
KeltnerChannel()(high=, low=, close=)→ {"upper", "middle", "lower"}
DonchianChannel(period=20)(high=, low=)→ {"highest", "lowest"}
IchimokuCloud()(high=, low=, close=)Ichimoku Cloud components
ParabolicSAR()(high=, low=, close=)Parabolic Stop and Reverse → float
Envelope(period=20, pct=2.5)(closes)Price envelope → {"upper", "lower"}

Orders & positions

Order methods

All order methods return an Order object. Use positive quantity for buy, negative for sell.

self.market_order(symbol, quantity) → Order

Submit a market order. Fills at the next bar's open price (or intrabar if enabled). Positive qty = buy, negative = sell.

self.limit_order(symbol, quantity, price) → Order

Submit a limit order. Buy limit fills at price or better (lower). Sell limit fills at price or better (higher).

self.stop_order(symbol, quantity, stop_price) → Order

Submit a stop-market order. Triggers a market order when the stop price is hit.

self.stop_limit_order(symbol, quantity, stop_price, limit_price) → Order

Submit a stop-limit order. When stop price is hit, a limit order is placed at the limit price.

self.trailing_stop(symbol, quantity, trail_amount=None, trail_percent=None) → Order

Submit a trailing stop order. Specify either an absolute trail_amount or a trail_percent (not both). The stop price adjusts as price moves in your favor.

Position management

Method / PropertyDescription
self.is_flat(symbol)True if no open position
self.is_long(symbol)True if long position (quantity > 0)
self.is_short(symbol)True if short position (quantity < 0)
self.position_size(symbol)Current quantity — positive = long, negative = short, 0 = flat
self.close_position(symbol)Close entire position with a market order
self.cancel_all_orders(symbol=None)Cancel all pending orders. Optionally filter by symbol. Returns count cancelled.

Advanced orders

self.bracket_order(symbol, quantity, take_profit_price, stop_loss_price, entry_price=None) → {"entry", "take_profit", "stop_loss"}

Submit a bracket (OCO) order. Places an entry order with automatic take-profit and stop-loss. When one side fills, the other is automatically cancelled. If entry_price is None, the entry is a market order; otherwise a limit order.

self.oco_order(symbol, order_a, order_b) → {"order_a", "order_b"}

One-Cancels-Other: submit two orders — when one fills, the other is cancelled. Each order dict: {"quantity", "price", "order_type": "limit"|"stop"}.

Bracket order with SMA entry signal
class MyStrategy(StrategyBase):
    """
    Bracket order example: enter with a market order,
    automatically set take-profit and stop-loss levels.
    When one side fills, the other is cancelled.
    """
    def on_init(self):
        self.params.setdefault('period', 20)
        self.params.setdefault('tp_pct', 5.0)
        self.params.setdefault('sl_pct', 2.0)

    def on_data(self, bar):
        period = self.params['period']
        hist = self.history(bar.symbol, period)
        if len(hist) < period:
            return

        closes = [b.close for b in hist]
        sma = sum(closes) / period

        if self.is_flat(bar.symbol) and bar.close > sma:
            qty = max(1, int(self.portfolio.cash * 0.90 / bar.close))
            tp = bar.close * (1 + self.params['tp_pct'] / 100)
            sl = bar.close * (1 - self.params['sl_pct'] / 100)
            self.bracket_order(bar.symbol, qty,
                take_profit_price=tp,
                stop_loss_price=sl)

Order object properties

Method / PropertyDescription
order.statusCREATED, SUBMITTED, PARTIALLY_FILLED, FILLED, CANCELLED, REJECTED
order.filled_quantityQuantity filled so far
order.avg_fill_priceVolume-weighted average fill price
order.commissionCommission charged on this order
order.slippage_costSlippage cost on this order
order.remaining_quantityQuantity remaining to fill
order.is_activeTrue if SUBMITTED or PARTIALLY_FILLED
order.is_terminalTrue if FILLED, CANCELLED, or REJECTED

Execution algorithms

Split large orders across multiple bars to reduce market impact. Each returns an executor object with is_complete and fill_pct properties.

self.twap_order(symbol, quantity, num_slices=10) → TWAPExecutor

Time-Weighted Average Price. Splits the order into equal-sized market orders distributed over N bars.

self.vwap_order(symbol, quantity, num_slices=10, volume_profile=None) → VWAPExecutor

Volume-Weighted Average Price. Distributes order slices proportional to the volume profile. Pass a custom volume_profile list or let the engine use actual volume.

self.iceberg_order(symbol, quantity, visible_quantity, limit_price=None) → IcebergExecutor

Iceberg order. Shows only visible_quantity at a time. Automatically submits the next slice when the current one fills. Use limit_price for limit orders, omit for market.

self.pov_order(symbol, quantity, max_pct_of_volume=0.1) → POVExecutor

Percentage of Volume. Limits execution to max_pct_of_volume of each bar's volume. Continues until the full quantity is filled.

TWAP order split across 10 bars
class MyStrategy(StrategyBase):
    """
    Demonstrates TWAP execution algorithm.
    Splits a large order into equal-sized slices
    distributed across N bars.
    """
    def on_init(self):
        self.params.setdefault('period', 50)
        self.params.setdefault('slices', 10)

    def on_data(self, bar):
        period = self.params['period']
        hist = self.history(bar.symbol, period)
        if len(hist) < period:
            return

        closes = [b.close for b in hist]
        sma = sum(closes) / period

        if self.is_flat(bar.symbol) and bar.close > sma:
            qty = max(10, int(self.portfolio.cash * 0.90 / bar.close))
            # Split into 10 equal slices over 10 bars
            self.twap_order(bar.symbol, qty,
                num_slices=self.params['slices'])

        elif self.is_long(bar.symbol) and bar.close < sma:
            self.close_position(bar.symbol)

Portfolio API

Access portfolio state via self.portfolio.

Method / PropertyDescription
self.portfolio.equityTotal portfolio value (cash + positions at market)
self.portfolio.cashAvailable cash balance
self.portfolio.unrealized_pnlUnrealized profit/loss on open positions
self.portfolio.realized_pnlRealized profit/loss from closed trades
self.portfolio.total_pnlTotal P&L (realized + unrealized)
self.portfolio.total_return_pctTotal return as a percentage
self.portfolio.buying_powerAvailable buying power (considers margin if enabled)
self.portfolio.margin_usedMargin currently in use
self.portfolio.margin_availableRemaining margin available

Position object

# Access a specific position
pos = self.portfolio.get_position(bar.symbol)
if pos:
    print(pos.quantity)        # Number of shares
    print(pos.avg_cost)        # Average cost basis
    print(pos.realized_pnl)    # Realized P&L
    print(pos.unrealized_pnl(bar.close))  # Current unrealized P&L
    print(pos.is_long)         # True if long
    print(pos.total_commission) # Total commission paid

# Check if position exists
has_pos = self.portfolio.has_position(bar.symbol)
qty = self.portfolio.get_position_quantity(bar.symbol)

Custom charts & alerts

self.plot(chart_name, series_name, value) → None

Plot a value on a custom chart pane. chart_name groups series together, series_name identifies the line within that chart. Call once per bar to build a time series.

self.notify(message, level="info", data=None) → None

Send a notification/alert. Level can be "info", "warning", or "critical". Optional data dict for extra context. Alerts are shown in the results panel.

RSI + Z-Score with chart overlays
class MyStrategy(StrategyBase):
    """
    Demonstrates self.plot() for custom chart overlays.
    Plots RSI and Z-score on separate chart panes
    using built-in indicator classes.
    """
    def on_init(self):
        self.params.setdefault('period', 14)
        self.set_warmup(bars=50)

    def on_data(self, bar):
        period = self.params['period']
        hist = self.history(bar.symbol, period * 3)
        if len(hist) < period * 3:
            return
        closes = [b.close for b in hist]

        # Use built-in indicators (global classes)
        rsi = RSI(period=period)(closes)
        z = ZScore(period=period)(closes)

        # Plot on custom charts
        self.plot('RSI', 'rsi', rsi)
        self.plot('RSI', 'oversold', 30)
        self.plot('RSI', 'overbought', 70)
        self.plot('Z-Score', 'z', z)

        if self.is_flat(bar.symbol) and rsi < 30 and z < -2:
            qty = max(1, int(self.portfolio.cash * 0.95 / bar.close))
            self.market_order(bar.symbol, qty)
        elif self.is_long(bar.symbol) and rsi > 70:
            self.close_position(bar.symbol)

Bar consolidators

Aggregate lower-timeframe bars into higher timeframes directly in your strategy. Consolidators are available as global classes — no imports needed.

TimeConsolidator(minutes=60, callback=fn)

Aggregates bars by time. Feed 1-minute bars to get 5-minute, 1-hour, 4-hour, etc. The callback is called with a ConsolidatedBar when each period completes.

BarCountConsolidator(count=10, callback=fn)

Aggregates every N bars into one. Useful for fixed-count aggregation regardless of time.

RenkoConsolidator(brick_size=10.0, callback=fn)

Creates Renko bars based on price movement. A new brick is formed when price moves by brick_size from the previous brick's close.

RangeConsolidator(range_size=5.0, callback=fn)

Creates range bars with a fixed price range. A new bar forms when the high-low range exceeds range_size.

class MyStrategy(StrategyBase):
    """
    Multi-timeframe strategy using bar consolidators.
    Uses 1-hour bars derived from lower-timeframe data.
    """
    def on_init(self):
        # Create a 1-hour consolidator
        self.hourly = TimeConsolidator(
            minutes=60, callback=self.on_hourly)
        self.hourly_sma = None

    def on_data(self, bar):
        # Feed every bar into the consolidator
        self.hourly.update(bar)

        # Trade based on hourly signal + current bar
        if self.hourly_sma and self.is_flat(bar.symbol):
            if bar.close > self.hourly_sma:
                qty = max(1, int(self.portfolio.cash * 0.95 / bar.close))
                self.market_order(bar.symbol, qty)
        elif self.is_long(bar.symbol) and self.hourly_sma:
            if bar.close < self.hourly_sma:
                self.close_position(bar.symbol)

    def on_hourly(self, consolidated_bar):
        # Called when a 1-hour bar completes
        hist = self.history(length=50)
        if len(hist) >= 20:
            closes = [b.close for b in hist[-20:]]
            self.hourly_sma = SMA(period=20)(closes)
Tip: The ConsolidatedBar object has the same fields as a regular bar (open, high,low, close,volume) plus start_time,end_time, and bar_count.

Scheduling & state

self.set_warmup(bars=0) → None

Set warm-up period. on_data() won't be called until the specified number of bars have passed. Use this for indicators that need historical data to stabilize.

self.schedule(name, every_n_bars, callback) → None

Schedule a callback to run every N bars. The callback receives no arguments — use self.history() inside it to access data.

self.store

A persistent dict that survives between bars. Use it to store custom state like running totals, counters, or cross-bar signal data.

Periodic rebalance with self.schedule()
class MyStrategy(StrategyBase):
    """
    Demonstrates self.schedule() for periodic logic.
    Rebalances position every 20 bars based on momentum.
    """
    def on_init(self):
        self.params.setdefault('lookback', 20)
        self.schedule('rebalance', every_n_bars=20,
            callback=self.rebalance)

    def rebalance(self):
        lookback = self.params['lookback']
        hist = self.history(length=lookback + 1)
        if len(hist) < lookback + 1:
            return
        roc = (hist[-1].close - hist[0].close) / hist[0].close

        symbol = hist[-1].symbol
        if roc > 0.02 and self.is_flat(symbol):
            qty = max(1, int(self.portfolio.cash * 0.95 / hist[-1].close))
            self.market_order(symbol, qty)
            self.notify(f"Entered long: ROC={roc:.2%}", level="info")
        elif roc < -0.02 and self.is_long(symbol):
            self.close_position(symbol)
            self.notify(f"Exited: ROC={roc:.2%}", level="warning")

    def on_data(self, bar):
        # Scheduled callback handles the logic
        pass

Parameters

Define parameters in on_init with self.params.setdefault(). Parameters appear as adjustable inputs in the Playground sidebar and can be optimized using grid search, Bayesian optimization, or genetic algorithms.

def on_init(self):
    # These become sliders/inputs in the Playground
    self.params.setdefault('fast', 10)       # Fast MA period
    self.params.setdefault('slow', 30)       # Slow MA period
    self.params.setdefault('threshold', 2.0) # Entry threshold

def on_data(self, bar):
    fast = self.params['fast']   # Read current value
    slow = self.params['slow']
    # ... use parameters in your logic
Tip: When you run optimization, the engine sweeps through parameter combinations and ranks results by Sharpe ratio, return, or your chosen objective. Use self.params.setdefault() (not direct assignment) so the optimizer can inject values.

Restrictions

Strategy code runs in a sandboxed environment. The validator checks your code before execution.

Allowed imports

  • math
  • numpy / np
  • pandas / pd
  • statistics
  • collections, itertools, functools
  • datetime, decimal

Blocked

  • os, sys, subprocess
  • socket, requests, http
  • multiprocessing, threading
  • Builtins: exec, eval, compile, open, input
  • Builtins: __import__, globals, getattr, setattr
  • All __dunder__ attribute access
Warning: The history buffer holds a maximum of 500 bars per symbol. If your strategy needs more lookback, consider computing running averages incrementally using self.store.

Version control

Custom strategies support Git-style versioning in the Playground code editor:

Save

Persists your working copy without creating a version. Auto-saves periodically while editing.

Commit

Creates a new version with a title and optional description. Each commit is a snapshot you can restore later.

Revert

Restores your working copy to a previous version. You can also rename and delete strategies from the strategy list.

Ready to build?

Open the Playground and start with a template or write your own strategy from scratch.

Open Playground