ExPostFacto (ex_post_facto v0.2.1)

View Source

A comprehensive backtesting library for trading strategies written in Elixir.

ExPostFacto provides professional-grade backtesting capabilities with an intuitive API, built-in data validation, technical indicators, and performance optimization. Whether you're testing simple moving average strategies or complex multi-indicator systems, ExPostFacto makes it easy to validate your trading ideas with historical data.

Quick Start

# Sample market data
market_data = [
  %{open: 100.0, high: 105.0, low: 98.0, close: 102.0, timestamp: "2023-01-01"},
  %{open: 102.0, high: 108.0, low: 101.0, close: 106.0, timestamp: "2023-01-02"}
]

# Run a simple backtest
{:ok, result} = ExPostFacto.backtest(
  market_data,
  {MyStrategy, :call, []},
  starting_balance: 10_000.0
)

# Access results
IO.puts("Total P&L: $#{result.result.total_profit_and_loss}")
IO.puts("Win Rate: #{result.result.win_rate}%")

Key Features

  • Multiple Data Formats: CSV files, JSON, lists of maps
  • Data Validation & Cleaning: Automatic OHLCV validation and data cleaning
  • Strategy Framework: Simple MFA functions or advanced Strategy behaviours
  • Technical Indicators: 20+ built-in indicators (SMA, RSI, MACD, etc.)
  • Parameter Optimization: Grid search, random search, walk-forward analysis
  • Comprehensive Analytics: 30+ performance metrics and risk analysis
  • Streaming Support: Memory-efficient processing for large datasets
  • Concurrent Processing: Leverage multi-core systems for optimization

Strategy Development

ExPostFacto supports two approaches to strategy development:

Simple MFA Functions

defmodule MySimpleStrategy do
  def call(data, _result) do
    if data.close > 100.0, do: :buy, else: :sell
  end
end

Advanced Strategy Behaviours

defmodule MyAdvancedStrategy do
  use ExPostFacto.Strategy

  def init(opts) do
    {:ok, %{threshold: Keyword.get(opts, :threshold, 100.0)}}
  end

  def next(state) do
    if data().close > state.threshold do
      buy()
    else
      sell()
    end
    {:ok, state}
  end
end

Data Handling

ExPostFacto automatically handles various data formats:

# CSV files
{:ok, result} = ExPostFacto.backtest("market_data.csv", strategy)

# Lists of maps
{:ok, result} = ExPostFacto.backtest(market_data_list, strategy)

# JSON strings  
{:ok, result} = ExPostFacto.backtest(json_string, strategy)

Parameter Optimization

# Find optimal parameters
{:ok, result} = ExPostFacto.optimize(
  market_data,
  MyStrategy,
  [param1: 5..15, param2: 20..30],
  maximize: :sharpe_ratio
)

Main Functions

For detailed guides and examples, see the documentation in the docs/ directory.

Summary

Functions

The other main entry point of the library. This function takes in a list of HLOC data and a strategy that will be used to generate buy and sell signals.

The main entry point of the library. This function takes in a list of HLOC data and a strategy that will be used to generate buy and sell signals.

Streaming backtest for large datasets.

Enhanced backtest with comprehensive validation and error handling.

Cleans and preprocesses OHLCV data.

Generate a parameter heatmap from optimization results.

Provides access to technical indicators.

Loads data from various sources (CSV files, JSON, etc.).

Optimize strategy parameters using the specified optimization method.

Validates OHLCV data structure and values.

Types

action()

@type action() :: :buy | :sell | :close_buy | :close_sell

module_function_arguments()

@type module_function_arguments() ::
  {module :: atom(), function :: atom(), args :: list()}

strategy()

@type strategy() :: module_function_arguments() | strategy_module()

strategy_module()

@type strategy_module() :: {module :: atom(), opts :: keyword()}

Functions

backtest(data, strategy, options \\ [])

@spec backtest(
  data :: [ExPostFacto.DataPoint.t()] | String.t(),
  strategy :: strategy(),
  options :: keyword()
) :: {:ok, ExPostFacto.Output.t()} | {:error, String.t()}

The other main entry point of the library. This function takes in a list of HLOC data and a strategy that will be used to generate buy and sell signals.

The strategy can be either:

  • A traditional MFA tuple: {Module, :function, args}
  • A Strategy behaviour module: {Module, opts} where Module implements ExPostFacto.Strategy

Options may also be passed in for configuration.

The function returns an ok or error tuple. In an ok tuple, the data, and a results struct are returned. In an error tuple, a string is returned with the error message.

Supports multiple input formats:

  • List of maps (existing functionality)
  • CSV file path as string
  • JSON string or parsed data

Examples

iex> ExPostFacto.backtest(nil, {Foo, :bar, []})
{:error, "data cannot be nil"}

iex> ExPostFacto.backtest([], {Foo, :bar, []})
{:error, "data cannot be empty"}

iex> ExPostFacto.backtest([%{o: 1.0, h: 2.0, l: 0.5, c: 1.0}], nil)
{:error, "strategy cannot be nil"}

# Using traditional MFA tuple
iex> data = [%{o: 1.0, h: 2.0, l: 0.5, c: 1.0}]
iex> result = ExPostFacto.backtest(data, {ExPostFacto.ExampleStrategies.Noop, :noop, []})
iex> match?({:ok, %ExPostFacto.Output{}}, result)
true

# CSV file input
iex> ExPostFacto.backtest("nonexistent/path.csv", {MyStrategy, :call, []})
{:error, "failed to load data: failed to read file: enoent"}

# This would be used with Strategy behaviour modules
# iex> ExPostFacto.backtest(data, {MyStrategy, [param: 10]})

backtest!(data, strategy, options \\ [])

@spec backtest!(
  data :: [ExPostFacto.DataPoint.t()] | String.t(),
  strategy :: strategy(),
  options :: keyword()
) :: ExPostFacto.Output.t() | no_return()

The main entry point of the library. This function takes in a list of HLOC data and a strategy that will be used to generate buy and sell signals.

The strategy can be either:

  • A traditional MFA tuple: {Module, :function, args}
  • A Strategy behaviour module: {Module, opts} where Module implements ExPostFacto.Strategy

Options may also be passed in for configuration.

The function returns output struct or raises an error

Supports multiple input formats:

  • List of maps (existing functionality)
  • CSV file path as string
  • JSON string or parsed data

Examples

iex> ExPostFacto.backtest!(nil, {Foo, :bar, []})
** (ExPostFacto.BacktestError) data cannot be nil

iex> ExPostFacto.backtest!([], {Foo, :bar, []})
** (ExPostFacto.BacktestError) data cannot be empty

iex> ExPostFacto.backtest!([%{o: 1.0, h: 2.0, l: 0.5, c: 1.0}], nil)
** (ExPostFacto.BacktestError) strategy cannot be nil

# Using traditional MFA tuple
iex> data = [%{o: 1.0, h: 2.0, l: 0.5, c: 1.0}]
iex> output = ExPostFacto.backtest!(data, {ExPostFacto.ExampleStrategies.Noop, :noop, []})
iex> match?(%ExPostFacto.Output{}, output)
true

# CSV file input
iex> ExPostFacto.backtest!("nonexistent/path.csv", {MyStrategy, :call, []})
** (ExPostFacto.BacktestError) failed to load data: failed to read file: enoent

# This would be used with Strategy behaviour modules
# iex> ExPostFacto.backtest!(data, {MyStrategy, [param: 10]})

backtest_stream(data_source, strategy, options \\ [])

@spec backtest_stream(
  data_source :: String.t() | Enumerable.t(),
  strategy :: strategy(),
  options :: keyword()
) :: {:ok, ExPostFacto.Output.t()} | {:error, String.t()}

Streaming backtest for large datasets.

Provides memory-efficient backtesting for very large datasets that may not fit comfortably in memory. Uses chunked processing and streaming to manage memory usage.

Parameters

  • data_source - File path, stream, or large dataset
  • strategy - Trading strategy to apply
  • options - Options for streaming and backtesting

Options

  • :chunk_size - Number of data points per chunk (default: 1000)
  • :window_size - Rolling window size for strategy context (default: 100)
  • :overlap - Overlap between chunks for continuity (default: 10)
  • :memory_limit_mb - Memory limit in MB (default: 100)

Example

# Stream process a large CSV file
{:ok, output} = ExPostFacto.backtest_stream(
  "very_large_dataset.csv",
  {MyStrategy, []},
  chunk_size: 2000,
  memory_limit_mb: 200
)

backtest_with_enhanced_validation(data, strategy, options)

@spec backtest_with_enhanced_validation([map()], strategy(), keyword()) ::
  {:ok, ExPostFacto.Output.t()}
  | {:error,
     String.t()
     | ExPostFacto.Validation.ValidationError.t()
     | ExPostFacto.Validation.StrategyError.t()}

Enhanced backtest with comprehensive validation and error handling.

This version provides detailed error messages, warnings, and debugging support to improve the developer experience and catch issues early.

Options

  • :enhanced_validation - Enable enhanced validation (default: false for backward compatibility)
  • :debug - Enable debug mode for detailed logging
  • :strict - Enable strict validation mode
  • :warnings - Show runtime warnings (default: true)

Examples

# Enable enhanced validation
{:ok, output} = ExPostFacto.backtest(data, strategy, enhanced_validation: true)

# Enable debug mode
{:ok, output} = ExPostFacto.backtest(data, strategy, enhanced_validation: true, debug: true)

# Handle detailed errors
case ExPostFacto.backtest(invalid_data, strategy, enhanced_validation: true) do
  {:ok, output} -> output
  {:error, %ExPostFacto.Validation.ValidationError{} = error} ->
    IO.puts(ExPostFacto.Validation.format_error(error))
    :error
  {:error, error_message} ->
    IO.puts("Error: " <> error_message)
    :error
end

clean_data(data)

@spec clean_data(data :: [map()]) :: {:ok, [map()]} | {:error, String.t()}

Cleans and preprocesses OHLCV data.

Removes invalid data points, sorts by timestamp, and handles missing values.

Examples

iex> dirty_data = [
...>   %{open: 1.0, high: 2.0, low: 0.5, close: 1.5, timestamp: "2023-01-02"},
...>   %{open: nil, high: 1.8, low: 0.8, close: 1.2, timestamp: "2023-01-01"},
...>   %{open: 1.2, high: 1.9, low: 0.9, close: 1.4, timestamp: "2023-01-03"}
...> ]
iex> {:ok, cleaned} = ExPostFacto.clean_data(dirty_data)
iex> length(cleaned)
2

heatmap(optimization_result, x_param, y_param)

@spec heatmap(map(), atom(), atom()) :: {:ok, map()} | {:error, String.t()}

Generate a parameter heatmap from optimization results.

Creates a 2D visualization data structure for analyzing the parameter space of optimization results. Particularly useful for understanding the relationship between two parameters and their impact on strategy performance.

Parameters

  • optimization_result - Result from optimize/4 with :grid_search or :random_search
  • x_param - Parameter name for X-axis
  • y_param - Parameter name for Y-axis

Example

# First run optimization
{:ok, results} = ExPostFacto.optimize(
  data, MyStrategy,
  [fast: 5..15, slow: 20..40],
  method: :grid_search
)

# Generate heatmap
{:ok, heatmap} = ExPostFacto.heatmap(results, :fast, :slow)

# Use heatmap data for visualization
IO.inspect(heatmap.x_values)  # [5, 6, 7, ...]
IO.inspect(heatmap.y_values)  # [20, 21, 22, ...]
IO.inspect(heatmap.scores)    # [[0.1, 0.2, ...], [0.3, 0.4, ...]]

indicators()

@spec indicators() :: module()

Provides access to technical indicators.

This function delegates to ExPostFacto.Indicators module for calculating technical indicators used in trading strategies.

Examples

iex> prices = [10, 11, 12, 13, 14, 15]
iex> ExPostFacto.indicators().sma(prices, 3)
[nil, nil, 11.0, 12.0, 13.0, 14.0]

iex> ExPostFacto.indicators().crossover?([12, 11, 10], [10, 10, 10])
false

load_data_from_source(source)

@spec load_data_from_source(String.t()) :: {:ok, [map()]} | {:error, String.t()}

Loads data from various sources (CSV files, JSON, etc.).

Examples

iex> ExPostFacto.load_data_from_source("test/fixtures/sample.csv")
{:ok, [
  %{open: 100.0, high: 105.0, low: 98.0, close: 102.0, volume: 1000000.0, timestamp: "2023-01-01"},
  %{open: 102.0, high: 108.0, low: 101.0, close: 106.0, volume: 1200000.0, timestamp: "2023-01-02"},
  %{open: 106.0, high: 110.0, low: 104.0, close: 108.0, volume: 900000.0, timestamp: "2023-01-03"}
]}

optimize(data, strategy_module, param_ranges, opts \\ [])

@spec optimize(
  data :: [map()],
  strategy_module :: atom(),
  param_ranges :: [{atom(), Range.t() | [any()]}],
  opts :: keyword()
) :: {:ok, map()} | {:error, String.t()}

Optimize strategy parameters using the specified optimization method.

Finds optimal strategy parameters by running multiple backtests with different parameter combinations and evaluating them based on the target metric.

Parameters

  • data - Market data for backtesting
  • strategy_module - Strategy module to optimize (must implement ExPostFacto.Strategy)
  • param_ranges - Keyword list of parameter names to ranges
  • opts - Options including optimization method and target metric

Options

  • :method - Optimization method (:grid_search or :random_search) (default: :grid_search)
  • :maximize - Metric to optimize (default: :sharpe_ratio)
  • :starting_balance - Starting balance for backtests (default: 10_000.0)
  • :max_combinations - Maximum parameter combinations for grid search (default: 1000)
  • :samples - Number of samples for random search (default: 100)

Supported Metrics

  • :sharpe_ratio - Sharpe ratio (risk-adjusted return)
  • :total_return_pct - Total percentage return
  • :cagr_pct - Compound Annual Growth Rate
  • :profit_factor - Gross profit / gross loss
  • :sqn - System Quality Number
  • :win_rate - Percentage of winning trades
  • :max_draw_down_percentage - Maximum drawdown (minimized)

Examples

# Grid search optimization
{:ok, results} = ExPostFacto.optimize(
  market_data,
  MyStrategy,
  [fast: 5..20, slow: 20..50],
  maximize: :sharpe_ratio
)

# Random search with more samples
{:ok, results} = ExPostFacto.optimize(
  market_data,
  MyStrategy,
  [fast: 5..20, slow: 20..50],
  method: :random_search,
  samples: 200,
  maximize: :total_return_pct
)

# Access optimization results
IO.puts("Best parameters: #{inspect(result.best_params)}")
IO.puts("Best score: #{result.best_score}")

validate_data(data)

@spec validate_data(data :: [map()] | map()) :: :ok | {:error, String.t()}

Validates OHLCV data structure and values.

Returns :ok if data is valid, or {:error, reason} if invalid.

Examples

iex> ExPostFacto.validate_data([%{open: 1.0, high: 2.0, low: 0.5, close: 1.5}])
:ok

iex> ExPostFacto.validate_data([%{high: 1.0, low: 2.0, open: 1.5, close: 1.5}])
{:error, "data point 0: invalid OHLC data: high (1.0) must be >= low (2.0)"}

iex> ExPostFacto.validate_data([])
{:error, "data cannot be empty"}