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.
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.
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:
on_init— sets default parameters for fast (10) and slow (30) moving average periodson_data— called on every bar with the latest OHLCV dataself.history()— fetches recent bars to compute indicatorsself.is_flat()— checks if we have no open positionself.market_order()— places a buy order when the fast MA crosses above the slowself.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_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.
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 / Property | Description |
|---|---|
| bar.open | Opening price |
| bar.high | Highest price in the bar |
| bar.low | Lowest price in the bar |
| bar.close | Closing price |
| bar.volume | Volume traded |
| bar.symbol | Ticker symbol (e.g., "AAPL") |
| bar.timestamp | Bar datetime |
| bar.bar_index | Sequential bar number (0-based) |
| bar.mid | Computed: (high + low) / 2 |
| bar.typical_price | Computed: (high + low + close) / 3 |
| bar.range | Computed: 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:
returnStrategy properties
| Method / Property | Description |
|---|---|
| self.portfolio | Portfolio object — access cash, equity, positions, P&L |
| self.time | Current simulation datetime |
| self.bar_index | Current bar index (0-based) |
| self.params | Strategy parameters dict (set in on_init) |
| self.store | Persistent key-value store that persists between bars |
| self.is_warming_up | True 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.
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 / Property | Description |
|---|---|
| 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 / Property | Description |
|---|---|
| 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"}.
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 / Property | Description |
|---|---|
| order.status | CREATED, SUBMITTED, PARTIALLY_FILLED, FILLED, CANCELLED, REJECTED |
| order.filled_quantity | Quantity filled so far |
| order.avg_fill_price | Volume-weighted average fill price |
| order.commission | Commission charged on this order |
| order.slippage_cost | Slippage cost on this order |
| order.remaining_quantity | Quantity remaining to fill |
| order.is_active | True if SUBMITTED or PARTIALLY_FILLED |
| order.is_terminal | True 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.
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 / Property | Description |
|---|---|
| self.portfolio.equity | Total portfolio value (cash + positions at market) |
| self.portfolio.cash | Available cash balance |
| self.portfolio.unrealized_pnl | Unrealized profit/loss on open positions |
| self.portfolio.realized_pnl | Realized profit/loss from closed trades |
| self.portfolio.total_pnl | Total P&L (realized + unrealized) |
| self.portfolio.total_return_pct | Total return as a percentage |
| self.portfolio.buying_power | Available buying power (considers margin if enabled) |
| self.portfolio.margin_used | Margin currently in use |
| self.portfolio.margin_available | Remaining 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.
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)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.
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
passParameters
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 logicself.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
mathnumpy/nppandas/pdstatisticscollections,itertools,functoolsdatetime,decimal
Blocked
os,sys,subprocesssocket,requests,httpmultiprocessing,threading- Builtins:
exec,eval,compile,open,input - Builtins:
__import__,globals,getattr,setattr - All
__dunder__attribute access
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