Skip to contents

This article teaches the strategy contract from the user side: what a strategy can see, what it must return, and how to run a first backtest without hidden lookahead. For testing a strategy on one pulse, helper pipelines, share sizing, and strategy state, read vignette("strategy-authoring-tools", package = "ledgr").

Prerequisites

The examples use dplyr for demo-data preparation. Strategy functions use ledgr’s pulse context rather than data-frame operations. The article assumes basic familiarity with sealed snapshots (vignette("data-input-and-snapshots", package = "ledgr")); it introduces feature IDs where the first strategy needs one.

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

A backtest in ledgr is a sequence of decision moments. At each pulse, ledgr shows the strategy only what could have been known at that time. The strategy answers with desired holdings. ledgr records the decision, applies the fill model, and moves to the next pulse.

This matters because leakage is easy. If future information enters a historical decision, the backtest can look profitable for the wrong reason. ledgr’s strategy interface is built to make one common mistake harder: your strategy receives one pulse context, not the whole future. For the broader leakage model, including feature-construction leakage and remaining user responsibilities, see vignette("leakage", package = "ledgr").

The Causal Boundary

Strategy code receives one pulse context rather than a table containing future bars. That removes one easy route to accidental look-ahead, but it does not certify snapshot construction, feature logic, event timestamps, universe history, or parameter selection. vignette("leakage", package = "ledgr") works through the lead(close) example and the broader causal review; this article stays on the strategy contract.

With that boundary in mind, start with the simplest possible strategy.

What Is ctx?

ctx is the pulse context: the information packet ledgr gives your strategy at one decision time. It contains the current timestamp, current bars, current features, positions, cash, equity, and small helper functions for accessing those values. It is deliberately not the full future dataset.

Expression Meaning at one pulse
ctx$ts_utc current decision timestamp
ctx$universe instruments in the run
ctx$idx(id) 1-based universe position for one instrument
ctx$open(id), ctx$close(id) current bar values for one instrument
ctx$vec$close current close values for the full universe
ctx$feature(id, feature_id) current indicator value for one instrument by engine feature ID
ctx$vec$feature(feature_id) current indicator values for the full universe by engine feature ID
ctx$features(id, feature_map) mapped indicator values for one instrument by alias, using the supplied feature map
ctx$position(id) current simulated position
ctx$vec$position current simulated positions aligned to ctx$universe
ctx$cash, ctx$equity current simulated portfolio state
ctx$state_prev what the strategy returned as state_update at the previous pulse
ctx$flat() target zero positions unless changed
ctx$hold() target current positions unless changed

For the installed accessor reference, see ?ledgr_strategy_context.

One instrument or everyone

The ctx$vec$ forms return a value for the whole universe in one read. The scalar forms answer for one instrument you have already singled out. Both are correct; only the first is cheap when you want every name, because the scalar form costs per call and a universe-wide loop pays it once per instrument per pulse. vignette("strategy-authoring-tools", package = "ledgr") shows the plane-based pattern and documents the ledgr_scalar_accessor_loop warning emitted for that shape on large universes.

The pulse loop is the contract in motion:

flowchart TB
  state_t["pulse t state<br/>bars through t<br/>positions, cash, equity"]
  ctx_node["ctx<br/>pulse-known projection"]
  strategy_node["strategy(ctx, params)"]
  target_node["target vector<br/>desired holdings"]
  fill_node["next-open fill<br/>at t + 1"]
  state_next["pulse t + 1 state<br/>ledger updated"]

  state_t --> ctx_node --> strategy_node --> target_node --> fill_node --> state_next
  state_next -. next pulse .-> state_t

ctx is the no-lookahead handoff. It gives the strategy the current pulse projection, the strategy returns a target vector, and ledgr handles validation, fill timing, ledger events, and the next pulse.

The two target starters have different economic meanings:

Helper Starts from Economic meaning
ctx$flat() zero positions only hold what this pulse explicitly selects
ctx$hold() current positions keep existing positions unless changed

For example, this policy starts from current holdings and only changes the book when it sees an exit reason. Economically, it means: “keep what I already own, unless today’s bar gives me a reason to leave.”

hold_unless_down <- function(ctx, params) {
  down <- ctx$vec$close < ctx$vec$open
  targets <- ctx$hold()
  targets[down] <- 0
  targets
}

ctx$vec$close < ctx$vec$open compares every instrument at once, in the order of ctx$universe, and gives the condition a name: down holds one TRUE or FALSE per instrument. Assigning through it changes exactly the instruments where it is TRUE; every other instrument keeps its current quantity.

A Strategy That Does Nothing

The simplest economic policy is: hold cash and own no instruments.

flat_strategy <- function(ctx, params) {
  ctx$flat()
}

ctx$flat() creates a full target vector with one entry for every instrument in the run and every value set to zero. Economically, this means: after the next fill opportunity, hold no positions.

This is a complete ledgr strategy: useful for understanding the contract, not for making money.

The return value is a named numeric vector. Names are instrument IDs from ctx$universe, values are desired quantities. ctx$flat() produces the full-universe shape with every entry at zero.

A target vector is the strategy’s requested holdings for the full universe at one pulse. It is named by instrument ID, numeric, and complete. It is not an order list, a signal table, or a partial update. The deeper mental model is that a strategy is a policy, not a sequence of orders. At each pulse, it declares a desired state: “I want to hold this many shares of each instrument.” The engine compares that against current holdings and fills the gap.

That distinction keeps strategies free from execution-state bookkeeping. A target is intent: the layers after the strategy decide what is allowed, when it executes and what it costs, as vignette("risk-and-cost", package = "ledgr") explains.

Affordability is not automatic

A dense run fills a target whether or not the account can pay for it, so cash can go negative. vignette("execution-semantics", package = "ledgr") states the rule, including what availability-aware runs do instead.

A First Trading Rule

Now add one small economic idea:

If an instrument closes above its open, own one share. Otherwise own nothing.

This is still a teaching strategy, not investment advice. It shows how observable data becomes a target.

buy_if_up <- function(ctx, params) {
  up <- ctx$vec$close > ctx$vec$open
  targets <- ctx$flat()
  targets[up] <- 1
  targets
}

Read it as one decision for the whole universe: start from zero for every instrument, then set the instruments whose close is above their open to one share.

Targets are desired quantities, not orders, signals, or portfolio weights. A target of 1 means “after the next fill opportunity, hold one share.” A target of 0 means “hold no shares.”

In these examples, decisions fill at the next open: a decision made at pulse t fills at the next available bar. That keeps the strategy from deciding and filling on the same close.

A rule like this reads current values directly. When a rule needs to rank instruments or size positions from the account, the helper pipeline in vignette("strategy-authoring-tools", package = "ledgr") does that work.

Try it

Change buy_if_up() so it starts from ctx$hold() instead of ctx$flat(). Which positions would persist after a down bar, and why does that change the economic meaning of the strategy?

Why params Exists

params is the run’s strategy configuration. Put research choices you want to compare, store, or sweep into params; do not hide them in globals or inside feature declarations. Hard-coded constants make experiments awkward. Parameters let one economic idea run under different assumptions.

buy_if_up_qty <- function(ctx, params) {
  up <- ctx$vec$close > ctx$vec$open
  targets <- ctx$flat()
  targets[up] <- params$qty
  targets
}

Strategies use function(ctx, params). ctx is the pulse. params is the experimenter’s chosen configuration for this run. Keeping them separate makes the strategy easier to test, compare, and store. vignette("sweeps", package = "ledgr") runs one strategy across many params values.

Remembering Between Pulses

Most strategies decide from the current pulse alone. A strategy that has to remember something between pulses, such as how many pulses have passed or the price at which it bought, returns list(targets = ..., state_update = ...) instead of a bare target. ledgr stores the state_update with the run and hands it back as ctx$state_prev at the next pulse.

The state must be plain data: numbers, strings, logical values, and lists of them. A new state_update replaces the previous one; returning a bare target keeps it. On the first pulse there is nothing to remember yet, so check each field for NULL before using it.

vignette("strategy-authoring-tools", package = "ledgr") builds a strategy that uses state step by step. ?ledgr_strategy_context states the full rules, including the per-instrument asset_state of availability-aware runs.

Prepare A Small Experiment

Use two instruments from the offline demo data so the first full backtest can run anywhere.

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,
  snapshot_id = "strategy_chapter_snapshot"
)

features <- list(ledgr_ind_returns(5))

ledgr_feature_id(features)
#> [1] "return_5"

The snapshot seals the market data. features declares the pulse-known return input the strategy will read. Feature IDs are exact: a typo is an unknown feature, not warmup.

positive_return <- function(ctx, params) {
  ret <- ctx$vec$feature("return_5")
  if (!ledgr_passed_warmup(ret)) return(ctx$flat())
  rising <- ret > params$min_return
  targets <- ctx$flat()
  targets[rising] <- params$qty
  targets
}

Economically, this holds qty shares of every instrument whose 5-bar return is above min_return, and nothing otherwise. A 5-bar return needs five earlier closes, so for the first bars it is still NA. Both instruments share one calendar, so the return is missing for all of them at once, and ledgr_passed_warmup() holds nothing until every value exists. Handle warmup explicitly like this rather than letting a missing value slip through the assignment unseen. vignette("indicators", package = "ledgr") covers warmup in full.

The Same Rule With Helpers

Every strategy, however it is written, returns the same thing: shares per instrument. ledgr’s strategy helpers build that named vector for you in named steps. Here is the same rule as a helper pipeline:

positive_return_helpers <- function(ctx, params) {
  rising <- ledgr_signal_return(ctx, lookback = 5) > params$min_return
  ctx |>
    ledgr_selection(where = rising, missing = "exclude") |>
    ledgr_target_quantity(ctx, params$qty)
}

ledgr_signal_return() reads the registered 5-bar return, ledgr_selection() keeps the instruments where the condition holds, and missing = "exclude" says what a warmup NA means: not selected. ledgr_target_quantity() turns the selection into qty shares for the selected instruments and zero for the rest.

To see that both forms agree, ask each for its target on one decision date. ledgr_pulse_snapshot() builds the ctx a strategy would receive at one timestamp; vignette("strategy-authoring-tools", package = "ledgr") covers it in detail.

pulse <- ledgr_pulse_snapshot(
  snapshot,
  universe = c("DEMO_01", "DEMO_02"),
  ts_utc = ledgr_utc("2019-01-29"),
  features = features
)
params <- list(min_return = 0, qty = 10)

hand_built <- positive_return(pulse, params)
piped <- positive_return_helpers(pulse, params)

hand_built
#> DEMO_01 DEMO_02
#>      10      10
identical(unclass(piped), hand_built)
#> [1] TRUE

close(pulse)

The helpers return the same named vector, with a class that prints a short summary. Use whichever form makes the rule easier to read. The later articles use the helpers for rules like this one and write the vector by hand when manipulating it is the lesson.

The pipeline has four kinds of step. A rule that ranks instruments or sizes positions from the account swaps in the ranking and weighting steps:

Step Use
ledgr_signal_return() or ledgr_signal_feature() read one registered feature as comparable scores
ledgr_select_top_n(..., partial = ) or ledgr_selection(..., missing = ) choose instruments and state how incomplete inputs behave
ledgr_weight_equal() or ledgr_weights() divide the allocation budget
ledgr_target_rebalance(..., keep = ) convert weights into shares using the current account context

The weights carry only relative allocations. Rebalancing needs ctx again because current equity, prices, and holdings determine the share quantities. vignette("strategy-authoring-tools", package = "ledgr") builds a strategy with each step and its missing-input policy.

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

Run One Backtest

bt_first <- exp |>
  ledgr_run(
    params = list(min_return = 0, qty = 10),
    run_id = "positive_return_1"
  )
#> Warning: LEDGR_LAST_BAR_NO_FILL: target changed on the final available bar, but the
#> next-open fill model requires a following bar. No fill was emitted for this target
#> change. Check the strategy's final-pulse behavior or extend the snapshot if this trade
#> should be fillable.

summary(bt_first)
#> ledgr Backtest Summary
#> ======================
#>
#> Performance Metrics:
#>   Total Return:        1.20%
#>   Annualized Return:   2.37%
#>   Max Drawdown:        -0.82%
#>
#> Risk Metrics:
#>   Risk-Free Rate:      0.00% annual
#>   Annualization:       252 periods/year (US equity daily (default))
#>   Volatility (annual): 1.75%
#>   Sharpe Ratio:        1.346
#>
#> Trade Statistics:
#>   Closed Trades:       20
#>   Win Rate:            35.00%
#>   Avg Trade:           $6.62
#>
#> Exposure:
#>   Time in Market:      69.77%
#>
#> Execution Evidence:
#>   Fill Timing:         dense_bar_timestamp
#>
#> Corporate-Action Evidence:
#> Economic events: NOT SUPPLIED - returns may omit distributions
#> Price basis: UNDECLARED - distribution double counting cannot be ruled out
#>   Full policy record: ledgr_corporate_action_summary(bt)

The run prints a LEDGR_LAST_BAR_NO_FILL warning. The strategy changed its target on the last bar of the sample, and there is no later open at which to fill that change, so ledgr reports it rather than dropping it silently. Expect it at the end of most samples; vignette("execution-semantics", package = "ledgr") explains when it matters.

The summary is portfolio-level: total return, max drawdown, and trade count are computed from the completed run. In ledgr, a trade is a fill that closes quantity, fully or in part; the fills table can contain more rows because opening fills are recorded too.

The rendered result is a short, tiny-universe teaching run. Its annualized volatility, drawdown and return describe only that sample; none is evidence that the rule is attractive or that the annualization is stable. Read the numbers together: a positive final return can coexist with an intra-run drawdown, and 20 closed trades from a tiny synthetic sample are still far too little evidence for a strategy judgment.

Do not expect a teaching strategy to be good. A weak or unattractive result is still useful evidence: ledgr records failed ideas with the same care as successful ones, which is part of not fooling yourself.

Inspecting trades shows the actions produced by the target decisions.

ledgr_results(bt_first, what = "trades")
#> # A tibble: 20 x 10
#>    event_seq ts_utc     recording_pulse_ts_utc instrument_id side    qty price   fee
#>        <int> <date>     <dttm>                 <chr>         <chr> <dbl> <dbl> <dbl>
#>  1         2 2019-01-10 2019-01-10 00:00:00    DEMO_02       SELL     10  73.5     0
#>  2         5 2019-02-01 2019-02-01 00:00:00    DEMO_02       SELL     10  69.3     0
#>  3         6 2019-02-07 2019-02-07 00:00:00    DEMO_01       SELL     10  93.3     0
#>  4         9 2019-02-26 2019-02-26 00:00:00    DEMO_02       SELL     10  68.2     0
#>  5        11 2019-03-05 2019-03-05 00:00:00    DEMO_02       SELL     10  65.3     0
#>  6        13 2019-03-11 2019-03-11 00:00:00    DEMO_01       SELL     10 106.      0
#>  7        14 2019-03-11 2019-03-11 00:00:00    DEMO_02       SELL     10  68.0     0
#>  8        16 2019-03-15 2019-03-15 00:00:00    DEMO_02       SELL     10  67.6     0
#>  9        18 2019-03-20 2019-03-20 00:00:00    DEMO_02       SELL     10  67.2     0
#> 10        21 2019-04-05 2019-04-05 00:00:00    DEMO_01       SELL     10 103.      0
#> 11        23 2019-04-11 2019-04-11 00:00:00    DEMO_02       SELL     10  71.3     0
#> 12        25 2019-04-16 2019-04-16 00:00:00    DEMO_01       SELL     10 105.      0
#> 13        26 2019-04-29 2019-04-29 00:00:00    DEMO_02       SELL     10  75.9     0
#> 14        29 2019-05-16 2019-05-16 00:00:00    DEMO_01       SELL     10 101.      0
#> 15        30 2019-05-24 2019-05-24 00:00:00    DEMO_02       SELL     10  80.6     0
#> 16        32 2019-06-03 2019-06-03 00:00:00    DEMO_02       SELL     10  79.8     0
#> 17        34 2019-06-07 2019-06-07 00:00:00    DEMO_01       SELL     10  94.9     0
#> 18        37 2019-06-24 2019-06-24 00:00:00    DEMO_02       SELL     10  77.8     0
#> 19        38 2019-06-25 2019-06-25 00:00:00    DEMO_01       SELL     10  86.7     0
#> 20        40 2019-06-26 2019-06-26 00:00:00    DEMO_02       SELL     10  76.9     0
#> # i 2 more variables: realized_pnl <dbl>, action <chr>

The trade table has one row for each fill that closes quantity, whether it closes the whole position or only part of it. Here every row closes a whole 10-share position. realized_pnl is the profit or loss booked by that closing fill.

If a run has zero trades, inspect fills before assuming nothing happened:

ledgr_results(bt_first, what = "fills")
#> # A tibble: 41 x 10
#>    event_seq ts_utc     recording_pulse_ts_utc instrument_id side    qty price   fee
#>        <int> <date>     <dttm>                 <chr>         <chr> <dbl> <dbl> <dbl>
#>  1         1 2019-01-09 2019-01-09 00:00:00    DEMO_02       BUY      10  74.6     0
#>  2         2 2019-01-10 2019-01-10 00:00:00    DEMO_02       SELL     10  73.5     0
#>  3         3 2019-01-23 2019-01-23 00:00:00    DEMO_01       BUY      10  88.0     0
#>  4         4 2019-01-30 2019-01-30 00:00:00    DEMO_02       BUY      10  71.1     0
#>  5         5 2019-02-01 2019-02-01 00:00:00    DEMO_02       SELL     10  69.3     0
#>  6         6 2019-02-07 2019-02-07 00:00:00    DEMO_01       SELL     10  93.3     0
#>  7         7 2019-02-13 2019-02-13 00:00:00    DEMO_01       BUY      10  93.9     0
#>  8         8 2019-02-19 2019-02-19 00:00:00    DEMO_02       BUY      10  68.7     0
#>  9         9 2019-02-26 2019-02-26 00:00:00    DEMO_02       SELL     10  68.2     0
#> 10        10 2019-03-04 2019-03-04 00:00:00    DEMO_02       BUY      10  68.0     0
#> # i 31 more rows
#> # i 2 more variables: realized_pnl <dbl>, action <chr>

Zero fills means no execution occurred. Non-empty fills with zero trades means positions opened but did not close. n_trades counts fills that close quantity, while the fills table shows both opening and closing execution rows. For the full checklist when a run makes no trades, see vignette("indicators", package = "ledgr").

If you want to compare variants, keep the strategy authoring question separate from the research-comparison question. Use vignette("experiment-store", package = "ledgr") for stored-run comparison and vignette("sweeps", package = "ledgr") for promotion and review.

When ledgr Complains

ledgr tries to fail loudly when an error would make a backtest misleading. A strategy that forgets an instrument is the most common case. ledgr rejects the target instead of silently treating the missing instrument as zero:

only_one_name <- function(ctx, params) {
  c(DEMO_01 = 1)
}

tryCatch(
  ledgr_experiment(
    snapshot = snapshot,
    strategy = only_one_name,
    opening = ledgr_opening(cash = 10000),
    cost_model = ledgr_cost_zero()
  ) |>
    ledgr_run(run_id = "incomplete_target"),
  error = conditionMessage
)
#> [1] "`targets` must be a named numeric target vector, or ledgr_target, with names matching ctx$universe; missing instruments: DEMO_02."

The message names the instrument that is missing. Starting from ctx$flat() or ctx$hold() avoids this mistake, because both already contain every instrument. The other common failures are just as specific: a mistyped feature ID is reported together with the IDs that are registered, and ledgr_target_rebalance() refuses negative or over-allocated weights before turning them into share quantities.

Those errors are part of the design. They are meant to catch research mistakes while the mistake is still small enough to understand.

Cleanup

close(bt_first)
ledgr_snapshot_close(snapshot)

Where Next