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
@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:returnskey (or state isn't a map)%{sharpe: nil, ...}— state has:returnsbut 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
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:(default0) — passed to Sharpe and Sortinoperiods_per_year:(default365) — 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}
@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