Skip to content

Latest commit

 

History

History
513 lines (412 loc) · 15.1 KB

File metadata and controls

513 lines (412 loc) · 15.1 KB

ExPostFacto Tutorial: Building Your First Trading Strategy

Mix.install([
  {:ex_post_facto, path: Path.expand(".")}
])

Introduction

Welcome to the ExPostFacto tutorial! In this interactive notebook, you'll learn how to:

  1. Load and validate market data
  2. Create simple trading strategies
  3. Run backtests and analyze results
  4. Optimize strategy parameters
  5. Use technical indicators effectively

Let's start by exploring what ExPostFacto offers.

What is Backtesting?

Backtesting is the process of testing a trading strategy using historical data to see how it would have performed. This helps us:

  • Evaluate strategy performance before risking real money
  • Compare different strategies objectively
  • Optimize parameters for better results
  • Understand risk and drawdown characteristics

Setting Up Market Data

First, let's create some sample market data to work with:

# Create sample OHLCV data representing a trending market
sample_data = 
  1..100
  |> Enum.map(fn day ->
    base_price = 100 + day * 0.5  # Upward trend
    noise = (:rand.uniform() - 0.5) * 2  # Random noise
    
    open = base_price + noise
    close = base_price + noise + (:rand.uniform() - 0.5)
    high = max(open, close) + :rand.uniform() * 2
    low = min(open, close) - :rand.uniform() * 2
    
    %{
      open: Float.round(open, 2),
      high: Float.round(high, 2), 
      low: Float.round(low, 2),
      close: Float.round(close, 2),
      volume: 1_000_000 + :rand.uniform(500_000),
      timestamp: Date.add(~D[2023-01-01], day - 1) |> Date.to_string()
    }
  end)

# Let's look at the first few data points
Enum.take(sample_data, 5) |> IO.inspect(label: "Sample Data")

Data Validation

ExPostFacto includes built-in data validation to catch common issues:

# Validate our sample data
case ExPostFacto.validate_data(sample_data) do
  :ok -> 
    IO.puts("✅ Data validation passed!")
  {:error, reason} -> 
    IO.puts("❌ Data validation failed: #{reason}")
end

# Let's also try with some invalid data to see what happens
invalid_data = [
  %{open: 100.0, high: 95.0, low: 98.0, close: 102.0}  # high < low!
]

case ExPostFacto.validate_data(invalid_data) do
  :ok -> 
    IO.puts("✅ Invalid data somehow passed")
  {:error, reason} -> 
    IO.puts("❌ Correctly caught invalid data: #{reason}")
end

Your First Strategy: Buy and Hold

Let's start with the simplest possible strategy - buy and hold:

defmodule BuyAndHoldStrategy do
  @moduledoc """
  Simple buy and hold strategy - buy on the first day and hold forever.
  """
  
  use ExPostFacto.Strategy
  
  def init(_opts) do
    {:ok, %{bought: false}}
  end
  
  def next(state) do
    if not state.bought do
      buy()
      {:ok, %{state | bought: true}}
    else
      {:ok, state}
    end
  end
end

# Run the backtest
{:ok, result} = ExPostFacto.backtest(
  sample_data,
  {BuyAndHoldStrategy, []},
  starting_balance: 10_000.0
)

# Display results
IO.puts("=== Buy and Hold Results ===")
IO.puts("Total P&L: $#{Float.round(result.result.total_profit_and_loss, 2)}")
IO.puts("Total Return: #{Float.round(result.result.total_return_percentage, 2)}%")
IO.puts("Number of Trades: #{result.result.trades_count}")
IO.puts("Win Rate: #{Float.round(result.result.win_rate, 2)}%")

A More Sophisticated Strategy: Simple Moving Average

Now let's create a strategy that uses technical analysis:

defmodule SimpleSMAStrategy do
  @moduledoc """
  Simple Moving Average strategy:
  - Buy when price is above the SMA
  - Sell when price is below the SMA
  """
  
  use ExPostFacto.Strategy
  
  def init(opts) do
    sma_period = Keyword.get(opts, :sma_period, 20)
    
    {:ok, %{
      sma_period: sma_period,
      price_history: []
    }}
  end
  
  def next(state) do
    current_price = data().close
    updated_history = [current_price | state.price_history]
    
    # Only start trading when we have enough history
    if length(updated_history) >= state.sma_period do
      # Calculate SMA using built-in indicator
      sma_values = indicator(:sma, updated_history, state.sma_period)
      current_sma = List.first(sma_values)
      
      current_position = position()
      
      cond do
        # Price above SMA and not already long
        current_price > current_sma and current_position != :long ->
          if current_position == :short, do: close_sell()
          buy()
          
        # Price below SMA and not already short  
        current_price < current_sma and current_position != :short ->
          if current_position == :long, do: close_buy()
          sell()
          
        true ->
          :ok
      end
    end
    
    {:ok, %{state | price_history: updated_history}}
  end
end

# Test our SMA strategy
{:ok, sma_result} = ExPostFacto.backtest(
  sample_data,
  {SimpleSMAStrategy, [sma_period: 10]},
  starting_balance: 10_000.0
)

IO.puts("=== Simple SMA Strategy Results ===")
IO.puts("Total P&L: $#{Float.round(sma_result.result.total_profit_and_loss, 2)}")
IO.puts("Total Return: #{Float.round(sma_result.result.total_return_percentage, 2)}%")
IO.puts("Number of Trades: #{sma_result.result.trades_count}")
IO.puts("Win Rate: #{Float.round(sma_result.result.win_rate, 2)}%")
IO.puts("Best Trade: #{Float.round(sma_result.result.best_trade_percentage, 2)}%")
IO.puts("Worst Trade: #{Float.round(sma_result.result.worst_trade_percentage, 2)}%")

Comparing Strategies

Let's compare our strategies side by side:

strategies = [
  {"Buy & Hold", result.result},
  {"SMA Strategy", sma_result.result}
]

IO.puts("=== Strategy Comparison ===")
IO.puts(String.pad_trailing("Strategy", 15) <> " | " <> 
        String.pad_trailing("Total P&L", 12) <> " | " <> 
        String.pad_trailing("Return %", 10) <> " | " <> 
        String.pad_trailing("Trades", 8) <> " | " <> 
        "Win Rate %")
IO.puts(String.duplicate("-", 70))

for {name, result} <- strategies do
  IO.puts(
    String.pad_trailing(name, 15) <> " | " <>
    String.pad_trailing("$#{Float.round(result.total_profit_and_loss, 2)}", 12) <> " | " <>
    String.pad_trailing("#{Float.round(result.total_return_percentage, 2)}", 10) <> " | " <>
    String.pad_trailing("#{result.trades_count}", 8) <> " | " <>
    "#{Float.round(result.win_rate, 2)}"
  )
end

Using Built-in Indicators

ExPostFacto includes many technical indicators. Let's create a strategy using RSI:

defmodule RSIStrategy do
  @moduledoc """
  RSI mean reversion strategy:
  - Buy when RSI < 30 (oversold)
  - Sell when RSI > 70 (overbought)
  """
  
  use ExPostFacto.Strategy
  
  def init(opts) do
    rsi_period = Keyword.get(opts, :rsi_period, 14)
    oversold = Keyword.get(opts, :oversold, 30)
    overbought = Keyword.get(opts, :overbought, 70)
    
    {:ok, %{
      rsi_period: rsi_period,
      oversold: oversold,
      overbought: overbought,
      price_history: []
    }}
  end
  
  def next(state) do
    current_price = data().close
    updated_history = [current_price | state.price_history]
    
    # Need enough data for RSI calculation
    if length(updated_history) >= state.rsi_period + 1 do
      rsi_values = indicator(:rsi, updated_history, state.rsi_period)
      current_rsi = List.first(rsi_values)
      
      current_position = position()
      
      cond do
        # RSI oversold - buy signal
        current_rsi < state.oversold and current_position != :long ->
          if current_position == :short, do: close_sell()
          buy()
          
        # RSI overbought - sell signal
        current_rsi > state.overbought and current_position != :short ->
          if current_position == :long, do: close_buy()
          sell()
          
        true ->
          :ok
      end
    end
    
    {:ok, %{state | price_history: updated_history}}
  end
end

# Test RSI strategy
{:ok, rsi_result} = ExPostFacto.backtest(
  sample_data,
  {RSIStrategy, [rsi_period: 14, oversold: 25, overbought: 75]},
  starting_balance: 10_000.0
)

IO.puts("=== RSI Strategy Results ===")
IO.puts("Total P&L: $#{Float.round(rsi_result.result.total_profit_and_loss, 2)}")
IO.puts("Total Return: #{Float.round(rsi_result.result.total_return_percentage, 2)}%")
IO.puts("Number of Trades: #{rsi_result.result.trades_count}")
IO.puts("Win Rate: #{Float.round(rsi_result.result.win_rate, 2)}%")

Strategy Optimization

One of ExPostFacto's powerful features is parameter optimization. Let's find the best SMA period:

# Optimize the SMA strategy parameters
{:ok, optimization_result} = ExPostFacto.optimize(
  sample_data,
  SimpleSMAStrategy,
  [sma_period: 5..30],  # Test SMA periods from 5 to 30
  maximize: :total_return_percentage,
  starting_balance: 10_000.0
)

IO.puts("=== Optimization Results ===")
IO.puts("Best Parameters: #{inspect(optimization_result.best_params)}")
IO.puts("Best Score (Return %): #{Float.round(optimization_result.best_score, 2)}%")
IO.puts("Total Combinations Tested: #{length(optimization_result.all_results)}")

# Show top 5 parameter combinations
IO.puts("\n=== Top 5 Results ===")
optimization_result.all_results
|> Enum.sort_by(& &1.score, :desc)
|> Enum.take(5)
|> Enum.with_index(1)
|> Enum.each(fn {result, index} ->
  IO.puts("#{index}. SMA Period: #{result.params[:sma_period]}, Return: #{Float.round(result.score, 2)}%")
end)

Advanced Strategy: MACD Crossover

Let's create a more complex strategy using MACD (Moving Average Convergence Divergence):

defmodule MACDStrategy do
  @moduledoc """
  MACD crossover strategy:
  - Buy when MACD line crosses above signal line
  - Sell when MACD line crosses below signal line
  """
  
  use ExPostFacto.Strategy
  
  def init(opts) do
    fast_period = Keyword.get(opts, :fast_period, 12)
    slow_period = Keyword.get(opts, :slow_period, 26)
    signal_period = Keyword.get(opts, :signal_period, 9)
    
    {:ok, %{
      fast_period: fast_period,
      slow_period: slow_period,
      signal_period: signal_period,
      price_history: [],
      macd_history: [],
      signal_history: []
    }}
  end
  
  def next(state) do
    current_price = data().close
    updated_history = [current_price | state.price_history]
    
    # Need enough data for MACD calculation
    min_required = state.slow_period + state.signal_period
    if length(updated_history) >= min_required do
      # Calculate MACD and signal line
      {macd_line, signal_line, _histogram} = 
        indicator(:macd, updated_history, {state.fast_period, state.slow_period, state.signal_period})
      
      current_macd = List.first(macd_line)
      current_signal = List.first(signal_line)
      
      updated_macd_history = [current_macd | state.macd_history]
      updated_signal_history = [current_signal | state.signal_history]
      
      # Check for crossovers
      if length(updated_macd_history) >= 2 do
        current_position = position()
        
        cond do
          # MACD crosses above signal - buy
          crossover?(updated_macd_history, updated_signal_history) and current_position != :long ->
            if current_position == :short, do: close_sell()
            buy()
            
          # MACD crosses below signal - sell
          crossover?(updated_signal_history, updated_macd_history) and current_position != :short ->
            if current_position == :long, do: close_buy()
            sell()
            
          true ->
            :ok
        end
      end
      
      {:ok, %{state | 
        price_history: updated_history,
        macd_history: updated_macd_history,
        signal_history: updated_signal_history
      }}
    else
      {:ok, %{state | price_history: updated_history}}
    end
  end
end

# Test MACD strategy
{:ok, macd_result} = ExPostFacto.backtest(
  sample_data,
  {MACDStrategy, []},
  starting_balance: 10_000.0
)

IO.puts("=== MACD Strategy Results ===")
IO.puts("Total P&L: $#{Float.round(macd_result.result.total_profit_and_loss, 2)}")
IO.puts("Total Return: #{Float.round(macd_result.result.total_return_percentage, 2)}%")
IO.puts("Number of Trades: #{macd_result.result.trades_count}")
IO.puts("Win Rate: #{Float.round(macd_result.result.win_rate, 2)}%")

Analyzing Results

ExPostFacto provides comprehensive statistics. Let's examine them:

# Let's analyze the MACD strategy results in detail
result = macd_result.result

IO.puts("=== Detailed Performance Analysis ===")
IO.puts("Starting Balance: $10,000.00")
IO.puts("Ending Balance: $#{Float.round(10_000 + result.total_profit_and_loss, 2)}")
IO.puts("Total Return: #{Float.round(result.total_return_percentage, 2)}%")
IO.puts("")
IO.puts("=== Trade Statistics ===")
IO.puts("Total Trades: #{result.trades_count}")
IO.puts("Winning Trades: #{result.wins_count}")
IO.puts("Losing Trades: #{result.trades_count - result.wins_count}")
IO.puts("Win Rate: #{Float.round(result.win_rate, 2)}%")
IO.puts("")
IO.puts("=== Best/Worst Trades ===")
IO.puts("Best Trade: #{Float.round(result.best_trade_percentage, 2)}%")
IO.puts("Worst Trade: #{Float.round(result.worst_trade_percentage, 2)}%")
IO.puts("Average Trade: #{Float.round(result.average_trade_percentage, 2)}%")
IO.puts("")
IO.puts("=== Risk Metrics ===")
IO.puts("Max Drawdown: #{Float.round(result.max_draw_down_percentage, 2)}%")
IO.puts("Average Drawdown: #{Float.round(result.average_draw_down_percentage, 2)}%")

# Show some trade pairs if available
if length(result.trade_pairs) > 0 do
  IO.puts("\n=== Recent Trades ===")
  result.trade_pairs
  |> Enum.take(5)
  |> Enum.with_index(1)
  |> Enum.each(fn {trade, index} ->
    IO.puts("#{index}. #{trade.action} -> #{trade.close_action}: #{Float.round(trade.percentage, 2)}%")
  end)
end

Error Handling and Validation

ExPostFacto includes enhanced error handling. Let's see it in action:

# Try with invalid data
invalid_strategy_data = [
  %{open: 100, high: 90, low: 95, close: 85}  # Invalid: high < low
]

case ExPostFacto.backtest(
  invalid_strategy_data,
  {SimpleSMAStrategy, []},
  enhanced_validation: true,
  debug: true
) do
  {:ok, result} ->
    IO.puts("Unexpected success: #{inspect(result)}")
  {:error, error} ->
    IO.puts("Correctly caught error: #{error}")
end

# Test with valid data but enhanced validation
{:ok, enhanced_result} = ExPostFacto.backtest(
  sample_data,
  {SimpleSMAStrategy, [sma_period: 10]},
  enhanced_validation: true,
  debug: false,
  starting_balance: 10_000.0
)

IO.puts("Enhanced validation passed successfully!")

Next Steps

Congratulations! You've learned the basics of ExPostFacto. Here's what to explore next:

  1. Try different indicators: Experiment with Bollinger Bands, Stochastic, etc.
  2. Combine multiple signals: Create strategies that use multiple indicators
  3. Add risk management: Implement stop-losses and position sizing
  4. Test on real data: Load actual market data from CSV files
  5. Optimize parameters: Use grid search and walk-forward analysis
  6. Create custom indicators: Build your own technical indicators

Resources

  • Strategy API Guide - Detailed documentation on the Strategy behaviour
  • Best Practices - Guidelines for effective strategy development
  • Migration Guide - Moving from other backtesting libraries
  • Example Strategies - Check lib/ex_post_facto/example_strategies/ for more examples

Happy backtesting! 🚀