Skip to contents

You returned a target of 10 units. When does it trade, and at what price? A backtest that fills it on the same bar you decided on can quietly overstate your results.

This article shows how ledgr turns a target into a fill. You emit holdings, not orders, and they fill at the next bar’s open, not the bar you decided on. That one-bar rule is the no-lookahead boundary: it keeps the simulation from trading on information it could not have had at decision time. Read it when a fill surprises you, or before you trust a result that trades on every bar.

%%{init: {"theme": "base", "flowchart": {"nodeSpacing": 22, "rankSpacing": 24, "curve": "linear"}, "themeVariables": {"fontFamily": "system-ui, -apple-system, Segoe UI, sans-serif", "fontSize": "22px", "primaryColor": "#f8fafc", "primaryTextColor": "#1f2937", "primaryBorderColor": "#64748b", "lineColor": "#64748b", "tertiaryColor": "#eef2ff", "tertiaryTextColor": "#1f2937", "tertiaryBorderColor": "#64748b"}}}%%

flowchart LR
  pulse["Pulse t data"]
  target["Strategy target"]
  delta["Target delta"]
  fill["Next-open fill<br/>at t + 1"]
  ledger["Ledger state"]

  pulse --> target --> delta --> fill --> ledger

Clock Meaning
decision at pulse t the strategy sees pulse-known close and feature values and returns desired holdings
execution at pulse t + 1 the target delta may fill at the next observed open

A target produces no fill when there is no position change, when the next execution bar is absent, when the target is refused by availability or risk policy, or when the decision occurs on the final pulse and no next bar exists. The diagnostics surface distinguishes these outcomes.

library(ledgr)
library(dplyr)

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

The Lie A Backtest Can Tell You

Here is a momentum rule: hold one unit whenever a bar closes above its open. The decision uses bar t’s close, which ledgr treats as pulse-known information. The only open question is when the resulting position fills.

Compare two fill rules on the same signal and the same bars. One fills at the open of the bar you decided from; the other fills at the next bar’s open, the way ledgr does.

window <- ledgr_demo_bars |>
  filter(
    instrument_id == "DEMO_01",
    between(ts_utc, ledgr_utc("2019-01-01"), ledgr_utc("2019-06-30"))
  )

timing <- window |>
  arrange(ts_utc) |>
  mutate(
    closed_up = close > open,
    fill_same_bar = (close - open) / open,
    fill_next_open = (lead(close) - lead(open)) / lead(open)
  ) |>
  filter(closed_up, !is.na(fill_next_open))

tibble::tibble(
  fill_rule = c("same bar you decided on", "next open (what ledgr does)"),
  mean_return = c(mean(timing$fill_same_bar), mean(timing$fill_next_open)),
  win_rate = c(mean(timing$fill_same_bar > 0), mean(timing$fill_next_open > 0))
)
#> # A tibble: 2 x 3
#>   fill_rule                   mean_return win_rate
#>   <chr>                             <dbl>    <dbl>
#> 1 same bar you decided on         0.00997    1    
#> 2 next open (what ledgr does)     0.00155    0.559

Filling at the bar you decided from wins 100 percent of the time. That is not skill – you only ever bought bars you already knew had closed up, so the profit was decided before the trade. A 100 percent win rate is the signature of lookahead. The honest rule, next-open, wins about half the time, because the next bar does not know what the last one did.

ledgr does not allow this: a target decided on bar t always fills at the open of bar t + 1. The rest of this article shows that rule in the ledger.

Targets Are Holdings, Not Orders

Returning 10 means “I want to hold 10 units”, not “buy 10 units every bar”. If the current position already equals the target, there is no new fill – only changes fill.

bars <- ledgr_demo_bars |>
  filter(
    instrument_id == "DEMO_01",
    between(ts_utc, ledgr_utc("2019-01-07"), ledgr_utc("2019-01-15"))
  )

hold_then_flat <- function(ctx, params) {
  targets <- ctx$flat()
  ts <- ledgr_utc(ctx$ts_utc)
  if (ts >= ledgr_utc("2019-01-08") && ts < ledgr_utc("2019-01-10")) {
    targets["DEMO_01"] <- 10
  }
  targets
}

snapshot <- ledgr_snapshot_from_df(
  bars,
  db_path = tempfile(fileext = ".duckdb"),
  snapshot_id = "execution-semantics"
)

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

bt <- ledgr_run(
  exp,
  run_id = "how_targets_holdings",
  params = list()
)

ledgr_results(bt, what = "fills") |>
  select(ts_utc, side, qty, price)
#> # A tibble: 2 x 4
#>   ts_utc     side    qty price
#>   <date>     <chr> <dbl> <dbl>
#> 1 2019-01-09 BUY      10  88.4
#> 2 2019-01-11 SELL     10  88.8

The strategy asked to hold the position across two bars, but you see only two fills: one to open the position and one to close it. The bars in between asked for the same target, so nothing executed.

The Fill Is At The Next Open

Look at the dates. The strategy set the target on January 8, using that bar’s close. ledgr filled the buy at January 9’s open (88.4) – the next bar – not at January 8’s close. When the target went flat on January 10, the sell filled at January 11’s open.

The price you decide on is never the price you get. That is why a strategy should read ctx$vec$close and feature values as pulse-known information, not as executable prices. Each fill also carries a fee set by the cost model; this article keeps costs at zero to isolate timing (see vignette("risk-and-cost", package = "ledgr")).

Every run summary names this rule on its Fill Timing: line. dense_bar_timestamp means a dense run: a target decided on one bar fills at the next bar’s open, stamped with that bar’s timestamp. availability_open_v2 means an availability-aware run: decisions happen at declared session closes and fill at the next declared session opening.

Cash Is Not Checked In A Dense Run

A dense run fills a target whether or not the account can pay for it. Dense fills do not check cash, which can go negative when targets cost more than the account holds. An availability-aware run refuses a cash-consuming fill it cannot afford and records the refusal with reason insufficient_cash. When capital matters, size targets from ctx$cash and ctx$equity, or with ledgr_target_rebalance().

A Target On The Final Bar Cannot Fill

A target change on the final pulse is valid strategy output, but there is no later bar where ledgr can simulate the next-open fill. ledgr warns and leaves the ledger unchanged for that final target change.

final_bar_strategy <- function(ctx, params) {
  targets <- ctx$flat()
  if (ledgr_utc(ctx$ts_utc) == ledgr_utc("2019-01-15")) {
    targets["DEMO_01"] <- 10
  }
  targets
}

final_exp <- ledgr_experiment(
  snapshot,
  final_bar_strategy,
  opening = ledgr_opening(cash = 10000),
  cost_model = ledgr_cost_zero()
)

final_bt <- ledgr_run(
  final_exp,
  run_id = "how_targets_final_bar",
  params = list()
)
#> 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.

nrow(ledgr_results(final_bt, what = "fills"))
#> [1] 0

The warning carries the stable code LEDGR_LAST_BAR_NO_FILL (see ?LEDGR_LAST_BAR_NO_FILL). If that final target matters, extend the snapshot by one executable bar and run again. Do not suppress the warning and treat the missing fill as a completed trade.

Zero Fills And Zero Trades Are Different

Zero fills means no execution occurred. Non-empty fills with zero trades means the run opened or added to a position but no fill closed any quantity inside the sample.

open_and_hold <- function(ctx, params) {
  targets <- ctx$flat()
  if (ledgr_utc(ctx$ts_utc) >= ledgr_utc("2019-01-08")) {
    targets["DEMO_01"] <- 10
  }
  targets
}

open_exp <- ledgr_experiment(
  snapshot,
  open_and_hold,
  opening = ledgr_opening(cash = 10000),
  cost_model = ledgr_cost_zero()
)

open_bt <- ledgr_run(
  open_exp,
  run_id = "how_targets_open_position",
  params = list()
)
tibble(
  case = c(
    "opened and closed",
    "opened and still held",
    "final target cannot fill"
  ),
  fills = c(
    nrow(ledgr_results(bt, what = "fills")),
    nrow(ledgr_results(open_bt, what = "fills")),
    nrow(ledgr_results(final_bt, what = "fills"))
  ),
  trades = c(
    nrow(ledgr_results(bt, what = "trades")),
    nrow(ledgr_results(open_bt, what = "trades")),
    nrow(ledgr_results(final_bt, what = "trades"))
  )
)
#> # A tibble: 3 x 3
#>   case                     fills trades
#>   <chr>                    <int>  <int>
#> 1 opened and closed            2      1
#> 2 opened and still held        1      0
#> 3 final target cannot fill     0      0

The first run has two fills and one realized trade: one fill opens and the next closes the position. The open-position run has one fill and zero trades because the position remains open. The final-target run has neither. Start with fills when you debug execution. Trades are derived from fills, not from target changes: a trade row is a fill that closes quantity, fully or in part, and realizes PnL. A rebalance that trims a position therefore records a trade. ledgr has no paired entry/exit trade table.

The distinction is also visible in accounting. A closing fill carries realized_pnl; an open position remains in the equity curve’s positions_value even though it has no closed trade.

bind_rows(
  `opened and closed` = ledgr_results(bt, what = "fills") |>
    select(action, realized_pnl),
  `opened and still held` = ledgr_results(open_bt, what = "fills") |>
    select(action, realized_pnl),
  .id = "case"
)
#> # A tibble: 3 x 3
#>   case                  action realized_pnl
#>   <chr>                 <chr>         <dbl>
#> 1 opened and closed     OPEN           0   
#> 2 opened and closed     CLOSE          4.10
#> 3 opened and still held OPEN           0

bind_rows(
  `opened and closed` = ledgr_results(bt, what = "equity") |>
    slice_tail(n = 1),
  `opened and still held` = ledgr_results(open_bt, what = "equity") |>
    slice_tail(n = 1),
  .id = "case"
) |>
  select(case, positions_value, equity)
#> # A tibble: 2 x 3
#>   case                  positions_value equity
#>   <chr>                           <dbl>  <dbl>
#> 1 opened and closed                  0  10004.
#> 2 opened and still held            870.  9987.

The closed position left its profit in cash, so its equity is about 10,004 with nothing held. The held position is valued at its last close, 870 against the 884 it cost, so its equity of about 9,987 carries an unrealized loss that no trade has booked yet.

Try It

Try it

In the momentum comparison above, replace lead(close) and lead(open) with a two-bar lead (lead(close, 2), lead(open, 2)). Does the next-open win rate stay near a coin flip? Then change the signal to close < open (hold on down bars). Does the same-bar rule still win 100 percent of the time, and why?

Where Next