ZenQuant.Backtest (zen_quant v0.2.0)

Copy Markdown View Source

Pure-function backtest harness for evaluating trading strategies over recorded JSONL data.

Wraps ZenQuant.Recorder.Replay.reduce/4 — same data + same strategy = same result (deterministic, verifiable). This is the foundation for EIP-8004 trustless verification: validators re-execute the same backtest and compare results.

Usage

# Run a strategy over recorded data
{:ok, result} = Backtest.run("/tmp/session.jsonl", &my_strategy/2, %{}, type: :ticker)
result.state       # => final strategy state
result.entries_processed  # => number of entries processed

# Compute risk metrics from a list of period returns
metrics = Backtest.metrics([0.01, -0.005, 0.02, 0.003])
metrics.sharpe     # => Sharpe ratio

# Evaluate: run + auto-metrics (strategy must accumulate :returns key)
{:ok, result} = Backtest.evaluate("/tmp/session.jsonl", &tracker/2, %{returns: []})
result.metrics     # => %{sharpe: ..., sortino: ..., max_drawdown: ..., calmar: ...}

Strategy Convention

Strategies that want auto-metrics from evaluate/4 should accumulate a :returns key in their state (a list of per-period returns). This is documented, not enforced.

Summary

Functions

Runs a strategy and computes risk metrics if the final state contains a :returns key.

Computes standard risk metrics from a list of period returns.

Runs a strategy function over recorded JSONL entries.

Functions

evaluate(path, strategy, initial_state, opts \\ [])

@spec evaluate(Path.t(), (map(), term() -> term()), term(), keyword()) ::
  {:ok,
   %{state: term(), metrics: map() | nil, entries_processed: non_neg_integer()}}
  | {:error, term()}

Runs a strategy and computes risk metrics if the final state contains a :returns key.

Composition of run/4 + metrics/2. Strategies that accumulate a :returns key (list of per-period returns) in their state get automatic risk metrics.

Return values for metrics field

  • nil — strategy state doesn't have a :returns key (or state isn't a map)
  • %{sharpe: nil, ...} — state has :returns but insufficient data (< 2 elements)
  • %{sharpe: float(), ...} — full metrics computed

Options

All Replay filter options pass through: type:, from:, to:, speed:. Metrics options: risk_free_rate: (default 0), periods_per_year: (default 365).

Examples

{:ok, result} = Backtest.evaluate("/tmp/session.jsonl", &tracker/2, %{returns: []})
result.metrics  # => %{sharpe: ..., sortino: ..., ...} or nil

metrics(returns, opts \\ [])

@spec metrics(
  [number()],
  keyword()
) :: map()

Computes standard risk metrics from a list of period returns.

Pure function, no I/O. Delegates to ZenQuant.Risk for individual calculations.

Returns nil for risk metrics when length(returns) < 2 — avoids misleading values from insufficient data.

Options

  • risk_free_rate: (default 0) — passed to Sharpe and Sortino
  • periods_per_year: (default 365) — passed to Calmar

Examples

Backtest.metrics([0.01, -0.005, 0.02, 0.003, -0.01])
# => %{total_return: 0.018, count: 5, sharpe: ..., sortino: ..., max_drawdown: ..., calmar: ...}

Backtest.metrics([0.01])
# => %{total_return: 0.01, count: 1, sharpe: nil, sortino: nil, max_drawdown: nil, calmar: nil}

run(path, strategy, initial_state, opts \\ [])

@spec run(Path.t(), (map(), term() -> term()), term(), keyword()) ::
  {:ok, %{state: term(), entries_processed: non_neg_integer()}}
  | {:error, term()}

Runs a strategy function over recorded JSONL entries.

The strategy has signature (entry :: map(), state :: term()) -> new_state. Delegates to Replay.reduce/4 internally.

Options

All Replay filter options pass through: type:, from:, to:, speed:.

Examples

{:ok, result} = Backtest.run("/tmp/session.jsonl", fn entry, count ->
  count + 1
end, 0)
result.entries_processed  # => number of entries
result.state              # => final count