Version 0.1 Last updated: August 22, 2025

Strategy Custom Syntax

This comprehensive guide covers every aspect of writing trading strategies for the backtesting engine.

Table of Contents

  1. Strategy Structure - Function Signatures
  2. Backtest Config
  3. Data Access
  4. Technical Indicators
  5. Portfolio Management
  6. Order Management
  7. Memory and State
  8. Advanced Patterns
  9. Performance Considerations
  10. Limitations and Gotchas

Strategy Structure - Function Signatures

Strategy functions define the lifecycle of your trading logic, controlling when and how your strategy executes. These functions are automatically discovered and called by the backtesting engine at specific moments during simulation, creating a structured framework for strategy development.

The engine validates that required functions are present and properly defined before execution begins. Each function receives different parameters depending on its purpose, allowing access to market data, portfolio state, and strategy memory.

Required Function

on_bar_close is the core trading function called every minute during market hours. This is where your main trading logic resides:

def on_bar_close(market, portfolio, memory, session):
    """Main trading logic - executes every minute"""
    
    # Access market data
    spy_data = market['SPY']
    current_price = spy_data.close
    
    # Check portfolio state  
    current_position = portfolio.position('SPY')
    
    # Make trading decisions
    orders = []
    if current_position == 0 and current_price > spy_data.sma(20):
        orders.append({
            'symbol': 'SPY',
            'action': 'buy', 
            'quantity': 100
        })
    
    return orders

This function must return either a list of order dictionaries or None if no trades are desired.

Optional Functions

Initialization and Cleanup

on_strategy_start runs once at the beginning, before any market data processing:

def on_strategy_start(portfolio, memory, session):
    """Initialize strategy parameters and state"""
    memory['rsi_period'] = 14
    memory['position_size'] = 0.25
    print(f"Strategy started with ${portfolio.initial_capital:,}")

on_strategy_end runs once at completion, after all market data is processed:

def on_strategy_end(portfolio, memory, results):
    """Final cleanup and reporting"""
    print(f"Final return: {results['total_return_pct']:.2f}%")
    print(f"Total trades: {results['total_trades']}")

Daily Market Events

on_market_open executes at 9:30 AM ET each trading day:

def on_market_open(market, portfolio, memory, session):
    """Daily market open logic"""
    memory['daily_trades'] = 0
    print(f"Market open: {session.current_date()}")

on_market_close executes at 4:00 PM ET each trading day:

def on_market_close(market, portfolio, memory, session):
    """Daily market close logic"""
    daily_pnl = portfolio.daily_pnl()
    memory['daily_results'].append(daily_pnl)

Function Parameters

market (or data_contexts) provides access to market data for all configured symbols. Use market['SYMBOL'] to access price data and technical indicators.

portfolio manages your positions, cash, and trading operations. Query current positions, account equity, and execute trades through this interface.

memory is a persistent dictionary that maintains state between function calls. Store strategy parameters, intermediate calculations, and custom variables here.

session contains timing and simulation metadata including current timestamp, progress indicators, and market schedule information. This parameter is optional and automatically provided when functions expect it.

results (on_strategy_end only) contains final backtest metrics including returns, trades, and performance statistics.

Backtest Config

BACKTEST_CONFIG is a required dictionary that defines the parameters for your backtesting simulation. Every strategy must include this configuration at the top level to specify when to run, starting capital, and which assets to trade.

BACKTEST_CONFIG = {
    'start_date': '20230610',     # YYYYMMDD format
    'end_date': '20230615',       # YYYYMMDD format
    'initial_capital': 100000,    # Starting capital in USD
    'symbols': ['SPY', 'AAPL']    # List of symbols to trade
}

Optional Parameters

start_date and end_date define the simulation timeframe. Use YYYY-MM-DD format ('2023-06-10'). The start date must precede the end date, and both must be valid calendar dates.

initial_capital sets your starting portfolio value in USD. Minimum value is $1,000.

symbols is a list of asset symbols your strategy can trade. Each symbol must have available market data for your specified date range.

commission_per_share (coming soon!)

slippage_bps (coming soon!)

Date Format Flexibility

While both date formats work, YYYYMMDD is recommended for consistency:

# Both formats are valid
'start_date': '20230610'      # Recommended
'start_date': '2023-06-10'    # Also accepted

The engine validates your configuration before execution, checking for required fields, valid dates, sufficient capital, and data availability for all symbols.

Data Access

DataContext Object

Access market data through market['SYMBOL'].

Price Properties

aapl = market['AAPL']

# Current bar OHLCV data
open_price = aapl.open      # float: Opening price
high = aapl.high            # float: High price  
low = aapl.low              # float: Low price
close = aapl.close          # float: Closing price
volume = aapl.volume        # int: Volume
timestamp = aapl.timestamp  # pd.Timestamp: Bar timestamp

Data Availability

  • Minute-level data: All properties represent 1-minute bars
  • Market hours only: Data available during regular trading hours
  • Point-in-time: Only data up to current bar (no look-ahead bias)

Symbol Validation

def on_bar_close(market, portfolio, memory, session):
    # Always check if symbol exists
    if 'AAPL' not in market:
        return None
    
    aapl = market['AAPL']
    # Now safe to use aapl

Technical Indicators

All indicators return float values or NaN when insufficient data.

Simple Moving Average (SMA)

sma_value = symbol.sma(period)

Parameters:

  • period (int): Number of bars for average

Returns:

  • float: SMA value
  • NaN: If current_index + 1 < period

Example:

spy = market['SPY']
sma_10 = spy.sma(10)    # 10-period SMA
sma_50 = spy.sma(50)    # 50-period SMA

# Check for valid data
if not pd.isna(sma_10) and not pd.isna(sma_50):
    if sma_10 > sma_50:
        # Golden cross logic
        pass

Exponential Moving Average (EMA)

ema_value = symbol.ema(period)

Parameters:

  • period (int): Number of bars for EMA

Returns:

  • float: EMA value using pandas ewm with span=period, adjust=False
  • NaN: If current_index + 1 < period

Example:

ema_12 = spy.ema(12)
ema_26 = spy.ema(26)

# MACD-like logic
if not pd.isna(ema_12) and not pd.isna(ema_26):
    macd_line = ema_12 - ema_26

Relative Strength Index (RSI)

rsi_value = symbol.rsi(period=14)

Parameters:

  • period (int, optional): Lookback period, default 14

Returns:

  • float: RSI value between 0 and 100
  • NaN: If current_index + 1 < period + 1

Example:

rsi = spy.rsi()           # Default 14-period
rsi_21 = spy.rsi(21)      # Custom 21-period

# Overbought/oversold levels
if not pd.isna(rsi):
    if rsi < 30:
        # Oversold condition
        pass
    elif rsi > 70:
        # Overbought condition  
        pass

Historical Data Access

values = symbol.history(field, periods)

Parameters:

  • field (string): Column name - 'open', 'high', 'low', 'close', 'volume'
  • periods (int): Number of historical bars to retrieve

Returns:

  • list[float]: Historical values, most recent last
  • Returns available data if periods > available bars

Example:

# Get last 5 closing prices
close_prices = spy.history('close', 5)
# close_prices = [100.0, 101.0, 99.5, 102.0, 103.0]
#                 oldest ----------> newest

# Check for trend
if len(close_prices) >= 5:
    if close_prices[-1] > close_prices[0]:  # Recent > oldest
        # Uptrend over 5 bars
        pass

# Volume analysis
volumes = spy.history('volume', 10)
avg_volume = sum(volumes) / len(volumes)

Portfolio Management

Position Queries

# Get current position size
shares = portfolio.position(symbol)

Parameters:

  • symbol (string): Symbol name (case-sensitive)

Returns:

  • int: Number of shares owned (positive) or sold short (negative)
  • 0: No position

Example:

spy_shares = portfolio.position('SPY')
aapl_shares = portfolio.position('AAPL')

if spy_shares > 0:
    print(f"Long {spy_shares} shares of SPY")
elif spy_shares < 0:
    print(f"Short {abs(spy_shares)} shares of SPY")
else:
    print("No SPY position")

Portfolio Value Queries

# Total portfolio value
total_value = portfolio.equity()

# Available cash for trading
cash = portfolio.buying_power()

# Initial starting capital (constant)
initial = portfolio.initial_capital

Methods:

Method Returns Description
equity() float Total portfolio value (cash + positions)
buying_power() float Available cash for purchases
initial_capital float Starting capital (property, not method)

Example:

def on_bar_close(market, portfolio, memory, session):
    total_value = portfolio.equity()
    cash = portfolio.buying_power()
    
    # Calculate position sizing as % of portfolio
    position_value = total_value * 0.1  # 10% of portfolio
    spy_price = market['SPY'].close
    shares_to_buy = int(position_value / spy_price)
    
    if cash >= shares_to_buy * spy_price:
        return {'symbol': 'SPY', 'action': 'buy', 'quantity': shares_to_buy}

Order Management

Order Format

Return order dictionaries from on_bar_close() to execute trades.

Single Order

order = {
    'symbol': 'AAPL',      # Required: Symbol name
    'action': 'buy',       # Required: 'buy' or 'sell'
    'quantity': 100        # Required: Number of shares or 'all'
}
return order

Multiple Orders

orders = [
    {'symbol': 'SPY', 'action': 'buy', 'quantity': 100},
    {'symbol': 'QQQ', 'action': 'sell', 'quantity': 50},
    {'symbol': 'AAPL', 'action': 'sell', 'quantity': 'all'}
]
return orders

Order Fields

Field Type Required Values Description
symbol string Yes Valid symbol Must match symbol in market
action string Yes 'buy', 'sell' Order direction
quantity int/string Yes Positive integer or 'all' Shares to trade

Special Quantity Values

Sell All Shares

# Sell entire position
return {'symbol': 'SPY', 'action': 'sell', 'quantity': 'all'}

Dynamic Quantity Calculation

def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    current_position = portfolio.position('SPY')
    
    # Sell half of current position
    if current_position > 0:
        half_position = current_position // 2
        if half_position > 0:
            return {'symbol': 'SPY', 'action': 'sell', 'quantity': half_position}

Order Validation

Orders are validated before execution:

  • Symbol exists: Must be in market
  • Valid action: Must be 'buy' or 'sell'
  • Valid quantity: Must be positive integer or 'all'
  • Sufficient cash: For buy orders
  • Sufficient shares: For sell orders

Invalid orders are ignored (no error thrown).

Memory and State

Use the memory (or state) dictionary to persist data between on_bar_close() calls.

Basic Usage

def on_strategy_start(market, portfolio, memory, session):
    memory['trade_count'] = 0
    memory['last_signal'] = None
    memory['price_history'] = []

def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    
    # Access previous values
    memory['price_history'].append(spy.close)
    
    # Keep only last 100 prices
    if len(memory['price_history']) > 100:
        memory['price_history'] = memory['price_history'][-100:]
    
    # Update counters
    if some_trade_condition:
        memory['trade_count'] += 1
        return some_order

Advanced State Management

def on_strategy_start(market, portfolio, memory, session):
    memory['signals'] = {
        'rsi': [],
        'sma_cross': [],
        'volume_spike': []
    }
    memory['positions'] = {}
    memory['last_rebalance'] = None

def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    current_time = spy.timestamp
    
    # Store multiple signal types
    memory['signals']['rsi'].append({
        'timestamp': current_time,
        'value': spy.rsi(14),
        'signal': 'buy' if spy.rsi(14) < 30 else 'sell' if spy.rsi(14) > 70 else 'hold'
    })
    
    # Track position changes
    current_position = portfolio.position('SPY')
    if current_position != memory['positions'].get('SPY', 0):
        memory['positions']['SPY'] = current_position
        print(f"SPY position changed to {current_position}")

Memory Best Practices

  1. Initialize in on_strategy_start(): Set up all memory structures
  2. Use descriptive keys: memory['sma_crossover_signals'] not memory['signals']
  3. Limit memory growth: Clean up old data to prevent memory issues
  4. Store computation results: Cache expensive calculations
def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    
    # Cache expensive computation
    cache_key = f"custom_indicator_{spy.timestamp}"
    if cache_key not in memory:
        # Expensive calculation
        memory[cache_key] = compute_complex_indicator(spy)
    
    indicator_value = memory[cache_key]

Advanced Patterns

Multi-Symbol Strategies

def on_bar_close(market, portfolio, memory, session):
    orders = []
    
    # Analyze each symbol
    for symbol in ['SPY', 'QQQ', 'IWM']:
        if symbol not in market:
            continue
            
        ctx = market[symbol]
        rsi = ctx.rsi(14)
        position = portfolio.position(symbol)
        
        # Individual symbol logic
        if rsi < 30 and position == 0:
            orders.append({'symbol': symbol, 'action': 'buy', 'quantity': 100})
        elif rsi > 70 and position > 0:
            orders.append({'symbol': symbol, 'action': 'sell', 'quantity': 'all'})
    
    return orders if orders else None

Sector Rotation Strategy

def on_bar_close(market, portfolio, memory, session):
    sectors = {
        'tech': ['QQQ', 'XLK'],
        'finance': ['XLF'], 
        'energy': ['XLE'],
        'healthcare': ['XLV']
    }
    
    # Calculate momentum for each sector
    sector_momentum = {}
    for sector, symbols in sectors.items():
        total_momentum = 0
        count = 0
        
        for symbol in symbols:
            if symbol in market:
                ctx = market[symbol]
                sma_20 = ctx.sma(20)
                sma_50 = ctx.sma(50)
                
                if not pd.isna(sma_20) and not pd.isna(sma_50):
                    momentum = (sma_20 - sma_50) / sma_50  # Relative momentum
                    total_momentum += momentum
                    count += 1
        
        if count > 0:
            sector_momentum[sector] = total_momentum / count
    
    # Find best performing sector
    if sector_momentum:
        best_sector = max(sector_momentum, key=sector_momentum.get)
        memory['current_sector'] = best_sector
        
        # Trade logic based on sector rotation
        # ... implementation details

Risk Management Patterns

def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    position = portfolio.position('SPY')
    
    # Stop loss management
    if position > 0:
        current_price = spy.close
        
        # Initialize stop price
        if 'stop_price' not in memory:
            memory['stop_price'] = current_price * 0.95  # 5% stop loss
        
        # Trailing stop
        if current_price > memory['entry_price'] * 1.02:  # 2% profit
            new_stop = current_price * 0.98  # Trail by 2%
            memory['stop_price'] = max(memory['stop_price'], new_stop)
        
        # Execute stop loss
        if current_price <= memory['stop_price']:
            memory.pop('stop_price', None)  # Clear stop
            return {'symbol': 'SPY', 'action': 'sell', 'quantity': 'all'}
    
    # Position sizing based on volatility
    if position == 0:
        recent_prices = spy.history('close', 20)
        if len(recent_prices) >= 20:
            volatility = pd.Series(recent_prices).pct_change().std()
            base_size = 1000  # Base position size
            
            # Reduce size if high volatility
            vol_adjusted_size = int(base_size / (1 + volatility * 10))
            
            # Entry condition with vol-adjusted sizing
            if spy.rsi(14) < 30:
                memory['entry_price'] = spy.close
                return {'symbol': 'SPY', 'action': 'buy', 'quantity': vol_adjusted_size}

Regime Detection

def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    
    # Detect market regime using multiple timeframes
    sma_50 = spy.sma(50)
    sma_200 = spy.sma(200)
    rsi = spy.rsi(14)
    
    # Price regime
    if not pd.isna(sma_50) and not pd.isna(sma_200):
        if sma_50 > sma_200:
            price_regime = 'bull'
        else:
            price_regime = 'bear'
    else:
        price_regime = 'unknown'
    
    # Volatility regime
    recent_prices = spy.history('close', 20)
    if len(recent_prices) >= 20:
        returns = pd.Series(recent_prices).pct_change().dropna()
        vol = returns.std()
        
        if vol < 0.01:
            vol_regime = 'low_vol'
        elif vol > 0.02:
            vol_regime = 'high_vol'
        else:
            vol_regime = 'normal_vol'
    else:
        vol_regime = 'unknown'
    
    # Adapt strategy based on regime
    memory['regime'] = {'price': price_regime, 'vol': vol_regime}
    
    # Regime-specific logic
    if price_regime == 'bull' and vol_regime == 'low_vol':
        # Aggressive long strategy
        if rsi < 40 and portfolio.position('SPY') == 0:
            return {'symbol': 'SPY', 'action': 'buy', 'quantity': 200}
    elif price_regime == 'bear' or vol_regime == 'high_vol':
        # Conservative or short strategy
        if portfolio.position('SPY') > 0:
            return {'symbol': 'SPY', 'action': 'sell', 'quantity': 'all'}

Performance Considerations

Computational Efficiency

  1. Cache Indicator Calculations
def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    
    # Don't recalculate on every bar
    if 'cached_sma' not in memory:
        memory['cached_sma'] = {}
    
    current_time = spy.timestamp
    cache_key = f"sma_20_{current_time}"
    
    if cache_key not in memory['cached_sma']:
        memory['cached_sma'][cache_key] = spy.sma(20)
    
    sma_20 = memory['cached_sma'][cache_key]
  1. Limit Memory Growth
def on_bar_close(market, portfolio, memory, session):
    # Keep only last N bars of data
    max_history = 100
    
    if 'price_history' not in memory:
        memory['price_history'] = []
    
    spy = market['SPY']
    memory['price_history'].append(spy.close)
    
    # Trim old data
    if len(memory['price_history']) > max_history:
        memory['price_history'] = memory['price_history'][-max_history:]
  1. Efficient Multi-Symbol Processing
def on_bar_close(market, portfolio, memory, session):
    orders = []
    
    # Pre-calculate common indicators once
    for symbol, ctx in market.items():
        # Only calculate if we don't have position or considering entry
        position = portfolio.position(symbol)
        if position != 0 or symbol in memory.get('watchlist', []):
            rsi = ctx.rsi(14)
            # Process symbol...

Memory Management

  1. Clean Up Old Data
def on_bar_close(market, portfolio, memory, session):
    current_time = market['SPY'].timestamp
    
    # Remove cached indicators older than 1 day
    if 'indicator_cache' in memory:
        cutoff_time = current_time - pd.Timedelta(days=1)
        memory['indicator_cache'] = {
            k: v for k, v in memory['indicator_cache'].items()
            if v.get('timestamp', current_time) > cutoff_time
        }
  1. Use Circular Buffers for Fixed-Size History
class CircularBuffer:
    def __init__(self, size):
        self.size = size
        self.data = []
        self.index = 0
    
    def append(self, value):
        if len(self.data) < self.size:
            self.data.append(value)
        else:
            self.data[self.index] = value
            self.index = (self.index + 1) % self.size
    
    def get_all(self):
        if len(self.data) < self.size:
            return self.data[:]
        else:
            return self.data[self.index:] + self.data[:self.index]

def on_strategy_start(market, portfolio, memory, session):
    memory['price_buffer'] = CircularBuffer(50)  # Keep only 50 prices

Limitations and Gotchas

Data Limitations

  1. No External Libraries
# ❌ NOT ALLOWED
import pandas as pd
import numpy as np
import talib

# ✅ USE BUILT-IN FUNCTIONALITY
def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    prices = spy.history('close', 20)
    avg_price = sum(prices) / len(prices)  # Manual average
  1. No Look-Ahead Bias
# ❌ WRONG - Using future data
def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    
    # This only gives you data UP TO current bar
    prices = spy.history('close', 5)
    # prices[4] is current bar, prices[0] is 4 bars ago
  1. Minute-Level Only
# Data is minute-by-minute during market hours
# No daily, weekly, or hourly aggregations available
# You must implement higher timeframe logic manually

Indicator Limitations

  1. NaN Handling
def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    sma = spy.sma(50)
    
    # ❌ WRONG - May crash on NaN
    if sma > spy.close:
        pass
    
    # ✅ CORRECT - Check for NaN first
    if not pd.isna(sma) and sma > spy.close:
        pass
    
    # ✅ ALTERNATIVE - Use math.isnan()
    import math
    if not math.isnan(sma) and sma > spy.close:
        pass
  1. Insufficient Data Periods
def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    
    # SMA(200) needs 200 bars of data
    # Will return NaN for first 199 bars
    sma_200 = spy.sma(200)
    
    # Check data availability
    if pd.isna(sma_200):
        return None  # Skip trading until enough data

Order Limitations

  1. Case-Sensitive Symbols
# ❌ WRONG
return {'symbol': 'aapl', 'action': 'buy', 'quantity': 100}

# ✅ CORRECT
return {'symbol': 'AAPL', 'action': 'buy', 'quantity': 100}
  1. Invalid Quantity Values
# ❌ WRONG
return {'symbol': 'SPY', 'action': 'buy', 'quantity': 0}      # Zero
return {'symbol': 'SPY', 'action': 'buy', 'quantity': -100}   # Negative
return {'symbol': 'SPY', 'action': 'buy', 'quantity': 1.5}    # Float

# ✅ CORRECT
return {'symbol': 'SPY', 'action': 'buy', 'quantity': 100}    # Positive int
return {'symbol': 'SPY', 'action': 'sell', 'quantity': 'all'} # Special value
  1. Symbol Availability
def on_bar_close(market, portfolio, memory, session):
    # ❌ WRONG - Assumes symbol exists
    aapl = market['AAPL']
    
    # ✅ CORRECT - Check first
    if 'AAPL' in market:
        aapl = market['AAPL']
    else:
        return None

Performance Gotchas

  1. Expensive Operations Every Bar
# ❌ INEFFICIENT - Recalculating every bar
def on_bar_close(market, portfolio, memory, session):
    for symbol in market:
        long_sma = market[symbol].sma(200)  # Expensive!
        # ... logic

# ✅ EFFICIENT - Cache and only recalculate when needed
def on_bar_close(market, portfolio, memory, session):
    if 'sma_cache' not in memory:
        memory['sma_cache'] = {}
    
    for symbol in market:
        if symbol not in memory['sma_cache']:
            memory['sma_cache'][symbol] = market[symbol].sma(200)
  1. Growing Memory Usage
# ❌ MEMORY LEAK - Unbounded growth
def on_bar_close(market, portfolio, memory, session):
    if 'all_prices' not in memory:
        memory['all_prices'] = []
    
    memory['all_prices'].append(market['SPY'].close)  # Grows forever!

# ✅ BOUNDED - Fixed size
def on_bar_close(market, portfolio, memory, session):
    if 'recent_prices' not in memory:
        memory['recent_prices'] = []
    
    memory['recent_prices'].append(market['SPY'].close)
    if len(memory['recent_prices']) > 1000:  # Keep only last 1000
        memory['recent_prices'] = memory['recent_prices'][-1000:]

Common Bugs

  1. Off-by-One in History
# ❌ WRONG - Misunderstanding history order
prices = spy.history('close', 5)
# prices[0] is OLDEST, prices[4] is NEWEST (current bar)

latest_price = prices[0]  # WRONG - This is 5 bars ago!

# ✅ CORRECT
latest_price = prices[-1]  # Last item is most recent
oldest_price = prices[0]   # First item is oldest
  1. State Initialization
# ❌ WRONG - Not initializing in on_strategy_start
def on_bar_close(market, portfolio, memory, session):
    memory['counter'] += 1  # May crash if not initialized!

# ✅ CORRECT - Initialize first
def on_strategy_start(market, portfolio, memory, session):
    memory['counter'] = 0

def on_bar_close(market, portfolio, memory, session):
    memory['counter'] += 1  # Safe
  1. Division by Zero
def on_bar_close(market, portfolio, memory, session):
    spy = market['SPY']
    
    # ❌ POTENTIAL ERROR
    ratio = spy.close / spy.open  # open could be 0!
    
    # ✅ SAFE
    if spy.open != 0:
        ratio = spy.close / spy.open
    else:
        ratio = 1.0  # or handle appropriately


Need help? Check the tutorial examples or consult the API documentation for endpoint-specific details.