Skip to contents

You want the smallest useful ledgr loop before reading the tool chapters.

In a few minutes you will have run one strategy on sealed demo data, tried a small grid of variants, and kept one candidate as a run you can reopen. That is the shortest useful path from demo data to inspectable evidence; it is not a validation protocol and not a production deployment path.

Load A Small Dataset

library(ledgr)
library(dplyr)

data("ledgr_demo_bars", package = "ledgr")

Use two demo instruments over the first half of 2019. The code writes to a temporary DuckDB store so the rendered article leaves no project artifacts behind.

store_path <- tempfile(fileext = ".duckdb")

bars <- ledgr_demo_bars |>
  filter(
    instrument_id %in% c("DEMO_01", "DEMO_02"),
    between(ts_utc, ledgr_utc("2019-01-01"), ledgr_utc("2019-06-30"))
  )

snapshot <- ledgr_snapshot_from_df(
  bars,
  db_path = store_path,
  snapshot_id = "quickstart_demo"
)

Declare The Experiment

Start with a visible rule: hold five units when the current close is above its 20-session moving average, otherwise hold zero. The strategy reads the whole decision axis at once. Until the average has 20 closes to work with it does not exist, and the strategy holds nothing.

features <- list(ledgr_ind_sma(20))

strategy <- function(ctx, params) {
  sma <- ctx$vec$feature("sma_20")
  if (!ledgr_passed_warmup(sma)) return(ctx$flat())
  above <- ctx$vec$close / sma - 1 > params$threshold
  targets <- ctx$flat()
  targets[above] <- params$qty
  targets
}

exp <- ledgr_experiment(
  snapshot = snapshot,
  strategy = strategy,
  features = features,
  opening = ledgr_opening(cash = 10000),
  cost_model = ledgr_cost_zero()
)

cost_model = ledgr_cost_zero() is explicit. If you want modeled costs, use a cost model such as ledgr_cost_spread_bps() or ledgr_cost_notional_bps_fee() rather than hiding costs inside the strategy.

Run One Case

Run one ordinary parameter set before sweeping. This catches setup mistakes while the result is still easy to inspect.

single_run <- ledgr_run(
  exp,
  params = list(qty = 5, threshold = 0),
  run_id = "quickstart_run",
  seed = 2026L
)

single_run
#> ledgr Backtest Results
#> ======================
#> 
#> Run ID:                            quickstart_run
#> Period:                            2019-01-01 to 2019-06-28
#> Opening Cash:                      $10000.00
#> Final Equity:                      $10041.77
#> Total Return:                      0.42%
#> Max Drawdown:                      -0.50%
#> Closed Trades:                     12
#> Metric Calendar:                   US equity daily (default) (252 days/year * 1 bars/day = 252 bars/year)
#> 
#> Economic events: NOT SUPPLIED - returns may omit distributions
#> Price basis: UNDECLARED - distribution double counting cannot be ruled out
#> 
#> Use summary(bt) for metrics and evidence

If this run has no fills, impossible prices, or surprising exposure, stop here. Sweeps amplify a setup; they do not repair it.

The last two lines say this demo supplied no dividend evidence and did not declare how its prices were adjusted, which is fine for learning the mechanics but not for an equity-research claim; vignette("corporate-action-cash", package = "ledgr") explains both.

Sweep A Tiny Grid

A sweep runs the same experiment once per parameter combination and keeps every result; vignette("sweeps", package = "ledgr") covers it in full. Vary only the strategy threshold. The feature remains the same concrete SMA used by the single run, so each candidate is easy to explain.

grid <- ledgr_strategy_grid(
  qty = 5,
  threshold = c(0, 0.01)
)

sweep <- ledgr_sweep(exp, grid, seed = 2026L)

sweep |>
  mutate(threshold = vapply(params, function(p) p$threshold, numeric(1))) |>
  select(candidate_id, threshold, status, total_return, sharpe_ratio) |>
  arrange(desc(sharpe_ratio))
#> # A tibble: 2 x 5
#>   candidate_id          threshold status total_return sharpe_ratio
#>   <chr>                     <dbl> <chr>         <dbl>        <dbl>
#> 1 strategy_7ccbbefd14d1      0.01 DONE        0.00529        1.25 
#> 2 strategy_86be010cf688      0    DONE        0.00418        0.838

Each row is one candidate: the threshold it ran with, its status and its result. The Sharpe ratio is annualized from half a year of two demo instruments, so at this size it describes the sample, not the rule. The sweep table is evidence, not an automatic recommendation. If you rank rows, make the ranking rule visible at the call site.

candidate <- ledgr_candidate(
  sweep |> arrange(desc(sharpe_ratio)),
  1L
)

candidate
#> ledgr_sweep_candidate
#> =====================
#> Strategy params:  {"qty":5.0,"threshold":0.01}
#> Feature params:   {}
#> Candidate ID:     strategy_7ccbbefd14d1
#> Status:           DONE
#> Execution seed:   603352633
#> Strategy:         89fb9e6adb02
#> Snapshot hash:    1f315cc620d0849849114d32cb44b042ec567f9512db99ad802c4ac89efe3a43
#> Feature-set hash: 790156753ee13f87911742cd1a70cfcf05124271e95c0f352b4ca10370601860
#> Evaluation scope: exploratory

The candidate carries its parameters and the hashes of the inputs it ran on, so the choice can be traced back to exactly what produced it.

Promotion is the step that turns one selected candidate into a durable run. In a real project, use a deliberate run_id and a note that explains why this row was selected.

promoted <- ledgr_promote(
  exp,
  candidate,
  run_id = "quickstart_promoted",
  note = "Highest Sharpe ratio in the tiny demo sweep."
)

promoted
#> ledgr Backtest Results
#> ======================
#> 
#> Run ID:                            quickstart_promoted
#> Period:                            2019-01-01 to 2019-06-28
#> Opening Cash:                      $10000.00
#> Final Equity:                      $10052.86
#> Total Return:                      0.53%
#> Max Drawdown:                      -0.53%
#> Closed Trades:                     11
#> Metric Calendar:                   US equity daily (default) (252 days/year * 1 bars/day = 252 bars/year)
#> 
#> Economic events: NOT SUPPLIED - returns may omit distributions
#> Price basis: UNDECLARED - distribution double counting cannot be ruled out
#> 
#> Use summary(bt) for metrics and evidence

This promotion is safe to execute here because the article uses a temporary store. It records the selection; it does not turn the tiny in-sample sweep into validation.

Where Next