defmodule Mix.Tasks.Backtest do @shortdoc "Evaluate a propagation feature against the QSO corpus" @moduledoc """ Runs `Microwaveprop.Backtest.evaluate/2` (plus the distance and band breakdowns) for a named feature function and prints a Markdown report to stdout. ## Usage mix backtest --feature naive_gradient mix backtest --feature NaiveGradient # CamelCase also works mix backtest --feature Microwaveprop.Backtest.Features.naive_gradient mix backtest --feature naive_gradient --sample 1000 --out priv/backtest_reports/naive.md mix backtest --all --out priv/backtest_reports/consolidated.md ## Options * `--feature` — fully-qualified `Module.function` or a short name that lives on `Microwaveprop.Backtest.Features`. Names are normalized via `Macro.underscore/1`, so `NaiveGradient`, `naive_gradient`, and `naiveGradient` all resolve to the same function. * `--all` — run all registered features and produce a consolidated pass/fail table. * `--sample` — max number of QSOs to evaluate (default: 5000). * `--baseline` — random-baseline sample size (default: same as `--sample`). * `--out` — optional file path to write the report to in addition to printing it. """ use Mix.Task alias Microwaveprop.Backtest alias Microwaveprop.Backtest.Features @impl Mix.Task def run(argv) do Mix.Task.run("app.start") Oban.pause_all_queues(Oban) {opts, _, _} = OptionParser.parse(argv, switches: [feature: :string, all: :boolean, sample: :integer, baseline: :integer, out: :string] ) if Keyword.get(opts, :all) do run_all(opts) else run_single(opts) end end defp run_all(opts) do sample_size = Keyword.get(opts, :sample, 5000) baseline_size = Keyword.get(opts, :baseline, sample_size) out_path = Keyword.get(opts, :out) features = Features.all_features() Mix.shell().info("Running consolidated backtest for #{map_size(features)} features...") results = Backtest.consolidated_report(features, sample_size: sample_size, baseline_size: baseline_size ) markdown = Backtest.to_consolidated_markdown(results) IO.puts(markdown) if out_path do File.mkdir_p!(Path.dirname(out_path)) File.write!(out_path, markdown) Mix.shell().info("Wrote consolidated report to #{out_path}") end end defp run_single(opts) do feature_spec = Keyword.fetch!(opts, :feature) sample_size = Keyword.get(opts, :sample, 5000) baseline_size = Keyword.get(opts, :baseline, sample_size) out_path = Keyword.get(opts, :out) {feature_fun, feature_name} = resolve_feature(feature_spec) report = Backtest.evaluate(feature_fun, sample_size: sample_size, baseline_size: baseline_size, feature_name: feature_name ) distance_bins = Backtest.lift_by_distance(feature_fun, sample_size: sample_size) band_stats = Backtest.lift_by_band(feature_fun, sample_size: sample_size) markdown = Backtest.to_markdown(report, distance_bins: distance_bins, band_stats: band_stats) IO.puts(markdown) if out_path do File.mkdir_p!(Path.dirname(out_path)) File.write!(out_path, markdown) Mix.shell().info("Wrote report to #{out_path}") end end defp resolve_feature(spec) do case String.split(spec, ".") do [name] -> fun = resolve_function_atom!(Features, name) feature_fun = &apply(Features, fun, [&1, &2, &3]) {feature_fun, "Microwaveprop.Backtest.Features.#{fun}"} parts -> {fun_name, mod_parts} = List.pop_at(parts, -1) module = Module.concat(mod_parts) fun = resolve_function_atom!(module, fun_name) feature_fun = &apply(module, fun, [&1, &2, &3]) {feature_fun, "#{inspect(module)}.#{fun}"} end end # Accept both `naive_gradient` and `NaiveGradient` and anything in between. defp resolve_function_atom!(module, name) do Code.ensure_loaded!(module) normalized = Macro.underscore(name) fun = String.to_atom(normalized) if function_exported?(module, fun, 3) do fun else exported = :functions |> module.__info__() |> Enum.filter(fn {_f, arity} -> arity == 3 end) |> Enum.map_join(", ", fn {f, _} -> to_string(f) end) Mix.raise("Feature #{inspect(module)}.#{normalized}/3 is not defined.\nAvailable 3-arity functions: #{exported}") end end end