LiveBook Integration Guide

View Source

Overview

LiveBook is Elixir's interactive and collaborative code notebook for data science, machine learning, and exploratory programming. This guide demonstrates how to integrate ExPostFacto with LiveBook to create interactive trading strategy backtesting and analysis workflows.

Key Benefits

  • Interactive Development: Test and refine trading strategies in real-time
  • Data Visualization: Rich charts and graphs using VegaLite and Kino
  • Collaborative Analysis: Share notebooks with team members
  • Rapid Prototyping: Quick iteration on strategy ideas
  • Documentation: Combine code, results, and explanations in one place

Prerequisites

Before starting, ensure you have:

  • Elixir 1.14+ installed
  • LiveBook installed and running
  • Basic understanding of Elixir and trading concepts

Installation

Option 1: Using LiveBook Desktop

  1. Download and install LiveBook Desktop
  2. Create a new notebook
  3. Add ExPostFacto as a dependency in the setup section:
Mix.install([
  {:ex_post_facto, "~> 0.2.0"},
  {:kino, "~> 0.12.0"},
  {:kino_vega_lite, "~> 0.1.0"}
])

Option 2: Using LiveBook Server

If running LiveBook as a server:

# Install LiveBook
mix escript.install hex livebook

# Start LiveBook
livebook server

Then add dependencies in your notebook as shown above.

Quick Start Example

Basic Backtesting in LiveBook

# Cell 1: Setup and Dependencies
Mix.install([
  {:ex_post_facto, "~> 0.2.0"},
  {:kino, "~> 0.12.0"},
  {:kino_vega_lite, "~> 0.1.0"}
])

alias VegaLite, as: Vl
# Cell 2: Sample Data Generation
defmodule SampleData do
  def generate_ohlc(days \\ 100, base_price \\ 100.0) do
    Enum.reduce(1..days, [], fn day, acc ->
      prev_close = if acc == [], do: base_price, else: hd(acc).close

      # Generate realistic OHLC data with some randomness
      open = prev_close + (:rand.uniform() - 0.5) * 2
      close = open + (:rand.uniform() - 0.5) * 3
      high = max(open, close) + :rand.uniform() * 2
      low = min(open, close) - :rand.uniform() * 2

      point = %{
        open: Float.round(open, 2),
        high: Float.round(high, 2),
        low: Float.round(low, 2),
        close: Float.round(close, 2),
        volume: :rand.uniform(1000000) + 500000,
        timestamp: Date.add(~D[2023-01-01], day - 1) |> Date.to_string()
      }

      [point | acc]
    end) |> Enum.reverse()
  end
end

# Generate 100 days of sample market data
market_data = SampleData.generate_ohlc(100)

IO.puts("Generated #{length(market_data)} data points")
IO.inspect(Enum.take(market_data, 3), label: "Sample data")
# Cell 3: Simple Moving Average Strategy
defmodule SMAStrategy do
  @doc "Simple Moving Average Crossover Strategy"
  def call(%{close: price}, %{data_points: data_points, is_position_open: is_position_open}) do
    # Get recent prices for moving averages
    recent_prices = [price | Enum.map(data_points, & &1.datum.close)]

    case length(recent_prices) do
      len when len < 20 ->
        :noop  # Not enough data
      _ ->
        # Calculate 10-day and 20-day simple moving averages
        sma_10 = recent_prices |> Enum.take(10) |> Enum.sum() |> Kernel./(10)
        sma_20 = recent_prices |> Enum.take(20) |> Enum.sum() |> Kernel./(20)

        cond do
          !is_position_open && sma_10 > sma_20 -> :buy    # Golden cross - buy signal
          is_position_open && sma_10 < sma_20 -> :close_buy  # Death cross - sell signal
          true -> :noop
        end
    end
  end
end
# Cell 4: Run Backtest
{:ok, result} = ExPostFacto.backtest(
  market_data,
  {SMAStrategy, :call, []},
  starting_balance: 100_000.0
)

# Display basic results
IO.puts("=== Backtest Results ===")
IO.puts("Starting Balance: $#{result.result.starting_balance}")
IO.puts("Final Balance: $#{result.result.final_balance}")
IO.puts("Total P&L: $#{result.result.total_profit_and_loss}")
IO.puts("Total Trades: #{length(result.result.trade_pairs)}")

# Get comprehensive statistics
stats = ExPostFacto.Result.comprehensive_summary(result.result)
IO.puts("Win Rate: #{Float.round(stats.win_rate_pct, 2)}%")
IO.puts("Sharpe Ratio: #{Float.round(stats.sharpe_ratio, 3)}")

Advanced Visualization Examples

Price Chart with Trade Signals

# Cell 5: Create Interactive Price Chart
defmodule ChartHelpers do
  def prepare_price_data(market_data) do
    Enum.with_index(market_data, fn data, index ->
      %{
        "index" => index,
        "date" => data.timestamp,
        "open" => data.open,
        "high" => data.high,
        "low" => data.low,
        "close" => data.close,
        "volume" => data.volume
      }
    end)
  end

  def prepare_trade_data(trade_pairs, market_data) do
    # Map trade pairs to chart points
    indexed_data = Enum.with_index(market_data)

    Enum.flat_map(trade_pairs, fn pair ->
      entry_index = Enum.find_index(indexed_data, fn {data, _} ->
        data.timestamp == pair.entry_timestamp
      end)

      exit_index = if pair.exit_timestamp do
        Enum.find_index(indexed_data, fn {data, _} ->
          data.timestamp == pair.exit_timestamp
        end)
      else
        nil
      end

      signals = []

      # Add entry signal
      if entry_index do
        signals = [%{
          "index" => entry_index,
          "price" => pair.entry_price,
          "type" => "BUY",
          "color" => "green"
        } | signals]
      end

      # Add exit signal
      if exit_index do
        signals = [%{
          "index" => exit_index,
          "price" => pair.exit_price,
          "type" => "SELL",
          "color" => "red"
        } | signals]
      end

      signals
    end)
  end
end

# Prepare data for visualization
price_data = ChartHelpers.prepare_price_data(market_data)
trade_signals = ChartHelpers.prepare_trade_data(result.result.trade_pairs, market_data)

# Create the price chart
price_chart =
  Vl.new(width: 800, height: 400)
  |> Vl.data_from_values(price_data)
  |> Vl.mark(:line, color: "steelblue")
  |> Vl.encode_field(:x, "index", type: :quantitative, title: "Time")
  |> Vl.encode_field(:y, "close", type: :quantitative, title: "Price ($)")
  |> Vl.resolve(:scale, y: :independent)

# Add trade signals as overlay
signal_chart =
  Vl.new()
  |> Vl.data_from_values(trade_signals)
  |> Vl.mark(:circle, size: 100)
  |> Vl.encode_field(:x, "index", type: :quantitative)
  |> Vl.encode_field(:y, "price", type: :quantitative)
  |> Vl.encode_field(:color, "color", type: :nominal, scale: [range: ["green", "red"]])
  |> Vl.encode_field(:tooltip, ["type", "price"])

# Combine charts
final_chart = Vl.layer([price_chart, signal_chart])

Kino.VegaLite.new(final_chart)

Performance Metrics Dashboard

# Cell 6: Performance Dashboard
defmodule Dashboard do
  def create_equity_curve(result, market_data) do
    # Calculate running equity over time
    equity_data =
      result.result.data_points
      |> Enum.with_index()
      |> Enum.map(fn {point, index} ->
        %{
          "index" => index,
          "equity" => point.running_balance,
          "date" => Enum.at(market_data, index).timestamp
        }
      end)

    Vl.new(width: 600, height: 300, title: "Equity Curve")
    |> Vl.data_from_values(equity_data)
    |> Vl.mark(:line, color: "green", stroke_width: 2)
    |> Vl.encode_field(:x, "index", type: :quantitative, title: "Time")
    |> Vl.encode_field(:y, "equity", type: :quantitative, title: "Portfolio Value ($)")
  end

  def create_trade_distribution(trade_pairs) do
    trade_data =
      trade_pairs
      |> Enum.map(fn pair ->
        pnl_pct = ((pair.exit_price - pair.entry_price) / pair.entry_price) * 100
        %{
          "pnl_percent" => Float.round(pnl_pct, 2),
          "trade_type" => if pnl_pct > 0, do: "Winner", else: "Loser"
        }
      end)

    Vl.new(width: 400, height: 300, title: "Trade P&L Distribution")
    |> Vl.data_from_values(trade_data)
    |> Vl.mark(:bar)
    |> Vl.encode_field(:x, "pnl_percent", type: :quantitative, bin: true, title: "P&L (%)")
    |> Vl.encode(:y, aggregate: :count, title: "Count")
    |> Vl.encode_field(:color, "trade_type", type: :nominal,
                      scale: [domain: ["Winner", "Loser"], range: ["green", "red"]])
  end
end

# Create equity curve
equity_chart = Dashboard.create_equity_curve(result, market_data)
Kino.VegaLite.new(equity_chart)
# Cell 7: Trade Distribution Chart
trade_dist_chart = Dashboard.create_trade_distribution(result.result.trade_pairs)
Kino.VegaLite.new(trade_dist_chart)

Interactive Strategy Development

Parameter Optimization

# Cell 8: Interactive Parameter Testing
defmodule ParameterOptimizer do
  def test_sma_parameters(market_data, short_periods, long_periods) do
    results = for short <- short_periods, long <- long_periods, short < long do
      strategy = fn data, context ->
        SMAStrategy.call_with_params(data, context, short, long)
      end

      {:ok, result} = ExPostFacto.backtest(
        market_data,
        {__MODULE__, :wrap_strategy, [strategy]},
        starting_balance: 100_000.0
      )

      stats = ExPostFacto.Result.comprehensive_summary(result.result)

      %{
        short_period: short,
        long_period: long,
        total_return: stats.total_return_pct,
        sharpe_ratio: stats.sharpe_ratio,
        win_rate: stats.win_rate_pct,
        max_drawdown: stats.max_drawdown_pct
      }
    end

    # Find best performing combination
    best = Enum.max_by(results, & &1.sharpe_ratio)

    {results, best}
  end

  def wrap_strategy(data, context, strategy_fn) do
    strategy_fn.(data, context)
  end
end

# Test different parameter combinations
short_periods = [5, 10, 15]
long_periods = [20, 30, 50]

{param_results, best_params} = ParameterOptimizer.test_sma_parameters(
  market_data,
  short_periods,
  long_periods
)

IO.puts("=== Parameter Optimization Results ===")
IO.puts("Best Parameters: #{best_params.short_period}/#{best_params.long_period}")
IO.puts("Sharpe Ratio: #{Float.round(best_params.sharpe_ratio, 3)}")
IO.puts("Total Return: #{Float.round(best_params.total_return, 2)}%")
IO.puts("Win Rate: #{Float.round(best_params.win_rate, 2)}%")

Real-time Strategy Testing

# Cell 9: Interactive Strategy Form
form =
  Kino.Control.form([
    short_ma: Kino.Control.number("Short MA Period", default: 10),
    long_ma: Kino.Control.number("Long MA Period", default: 20),
    initial_balance: Kino.Control.number("Starting Balance", default: 100_000)
  ], submit: "Run Backtest")

Kino.Control.stream(form)
|> Kino.listen(fn %{data: %{short_ma: short, long_ma: long, initial_balance: balance}} ->
  if short < long do
    # Create modified strategy with custom parameters
    custom_strategy = fn data, context ->
      SMAStrategy.call_with_params(data, context, short, long)
    end

    {:ok, result} = ExPostFacto.backtest(
      market_data,
      {ParameterOptimizer, :wrap_strategy, [custom_strategy]},
      starting_balance: balance
    )

    stats = ExPostFacto.Result.comprehensive_summary(result.result)

    IO.puts("\n=== Custom Strategy Results ===")
    IO.puts("Parameters: #{short}/#{long} MA")
    IO.puts("Starting Balance: $#{balance}")
    IO.puts("Final Balance: $#{Float.round(result.result.final_balance, 2)}")
    IO.puts("Total Return: #{Float.round(stats.total_return_pct, 2)}%")
    IO.puts("Sharpe Ratio: #{Float.round(stats.sharpe_ratio, 3)}")
    IO.puts("Win Rate: #{Float.round(stats.win_rate_pct, 2)}%")
    IO.puts("Max Drawdown: #{Float.round(stats.max_drawdown_pct, 2)}%")
  else
    IO.puts("Error: Short MA period must be less than Long MA period")
  end
end)

form

Loading Real Market Data

CSV Data Import

# Cell 10: Load Real Market Data
file_input = Kino.Input.file("Upload CSV file with OHLC data")
# Cell 11: Process Uploaded Data
file_data = Kino.Input.read(file_input)

real_market_data = if file_data do
  content = file_data.file_ref |> Kino.Input.file_path() |> File.read!()

  # Parse CSV data (assuming standard OHLC format)
  lines = String.split(content, "\n", trim: true)
  [_header | data_lines] = lines

  Enum.map(data_lines, fn line ->
    [date, open, high, low, close, volume] = String.split(line, ",")

    %{
      timestamp: String.trim(date),
      open: String.to_float(String.trim(open)),
      high: String.to_float(String.trim(high)),
      low: String.to_float(String.trim(low)),
      close: String.to_float(String.trim(close)),
      volume: String.to_integer(String.trim(volume))
    }
  end)
else
  IO.puts("No file uploaded, using sample data")
  market_data
end

IO.puts("Loaded #{length(real_market_data)} data points")
IO.inspect(Enum.take(real_market_data, 3), label: "Sample real data")

Best Practices

1. Notebook Organization

Structure your LiveBook notebooks with clear sections:

  • Setup: Dependencies and imports
  • Data Loading: Market data preparation
  • Strategy Definition: Trading logic
  • Backtesting: Running tests
  • Analysis: Results and visualization
  • Optimization: Parameter tuning

2. Performance Considerations

  • Use Enum.take/2 for large datasets in visualizations
  • Cache expensive computations using Kino.Process
  • Break complex analysis into smaller cells

3. Data Management

# Use Kino.Process to cache large datasets
data_process = Kino.Process.start_link(fn ->
  # Load and process your large dataset here
  heavy_market_data = load_large_dataset()
  heavy_market_data
end)

# Access cached data
cached_data = Kino.Process.get(data_process)

4. Error Handling

# Always validate data before backtesting
case ExPostFacto.validate_data(market_data) do
  :ok ->
    {:ok, result} = ExPostFacto.backtest(market_data, strategy)
    # Process results...

  {:error, reason} ->
    IO.puts("Data validation failed: #{reason}")
    # Handle error...
end

Common Use Cases

1. Strategy Research and Development

  • Interactive strategy prototyping
  • Parameter sensitivity analysis
  • Comparative backtesting

2. Educational and Training

  • Teaching trading concepts
  • Demonstrating strategy performance
  • Risk management education

3. Team Collaboration

  • Sharing analysis notebooks
  • Collaborative strategy development
  • Results presentation

4. Portfolio Analysis

  • Multi-strategy comparison
  • Risk-adjusted performance metrics
  • Correlation analysis

Troubleshooting

Common Issues

1. Dependency Loading Errors

# If you get dependency errors, restart the runtime and try:
Mix.install([
  {:ex_post_facto, "~> 0.2.0"},
  {:kino, "~> 0.12.0"},
  {:kino_vega_lite, "~> 0.1.0"}
], force: true)

2. Memory Issues with Large Datasets

# Process data in chunks for large files
defmodule DataProcessor do
  def process_in_chunks(data, chunk_size \\ 1000) do
    data
    |> Enum.chunk_every(chunk_size)
    |> Enum.map(&process_chunk/1)
    |> List.flatten()
  end

  defp process_chunk(chunk) do
    # Process each chunk separately
    chunk
  end
end

3. Visualization Performance

# Limit data points for charts
limited_data = Enum.take_every(large_dataset, 10)  # Take every 10th point

Additional Resources

Sample Notebooks

Complete example notebooks are available in the notebooks/ directory:

  • basic_backtesting.livemd - Introduction to backtesting in LiveBook
  • strategy_optimization.livemd - Parameter optimization workflows
  • advanced_visualization.livemd - Complex charting and analysis
  • real_data_analysis.livemd - Working with real market data

Happy backtesting! 🚀📈