Skip to contents

Your strategy needs a moving average, a return, or an RSI at each decision. By the end of this article you can declare those features once, predict their IDs and warmup, and read their pulse-known values inside a strategy without ever reaching into future data.

An indicator is a declared feature computation. ledgr computes indicators into pulse-known values; scalar accessors, mapped accessors, long tables, and wide tables are different views of that same pulse-known data. That model is the same for built-in ledgr indicators, TTR-backed indicators, and custom indicators.

Prerequisites

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

Start With Built-In Features

Use two demo instruments and two built-in features. The economic idea will be small on purpose:

Own an instrument only when its recent return is positive enough and today’s close is above its moving average.

That rule needs one momentum feature and one trend feature. A feature map gives your strategy code readable aliases while preserving ledgr’s exact engine feature IDs.

features <- ledgr_feature_map(
  ret_5 = ledgr_ind_returns(5),
  sma_10 = ledgr_ind_sma(10)
)

A feature ID is ledgr’s engine-facing name for a computed value. An alias is the strategy-facing name you choose in a feature map. Use aliases such as ret_5 or sma_10 when you write strategy logic with ctx$features(). Use feature IDs such as return_5 or sma_10 when you need the explicit engine contract, for example with ctx$feature() or when inspecting stored feature metadata.

Feature objects appear in three registration and inspection places:

Surface Accepted feature shape How names are used
ledgr_experiment(features = ...) indicator, list, named list, or feature map registers feature definitions for the run
ledgr_feature_contracts() / ledgr_feature_contract_check() static indicator, list, named list, or feature map reports aliases and engine IDs
ledgr_pulse_snapshot(features = ...) static list or feature map computes pulse-known values for inspection

The strategy context then exposes the computed values through accessors:

Accessor Name type Use
ctx$vec$feature(feature_id) active alias or engine feature ID reads one feature across the whole decision axis
ctx$feature(id, feature_id) active alias or engine feature ID reads one scalar value
ctx$features(id, feature_map) feature map returns a named vector keyed by alias

The canonical strategy workflow is: register features on ledgr_experiment(), resolve the engine ID once, then read the whole decision axis with ctx$vec$feature(). Keep ctx$feature() and ctx$features() for one-instrument inspection and for active aliases, whose concrete engine IDs vary by candidate.

Feature Lifecycle: From Declaration To Lookup

The feature path has five steps:

flowchart LR
  declare["declare<br/>indicator or map"]
  register["register<br/>experiment"]
  compute["compute<br/>pulse-known values"]
  access["access<br/>ctx feature methods"]
  decide["decide<br/>target holdings"]

  declare --> register --> compute --> access --> decide

  1. You declare features in any of several shapes: individual indicators, built-in helpers, TTR adapters, CSV/R adapters, feature maps, or active-alias parameterizations. All resolve to the same lifecycle below.
  2. ledgr_experiment() stores the declaration. Static lists and feature maps are ready immediately. Active-alias features are materialized for concrete feature-grid values before candidate execution.
  3. Optional ledgr_precompute_features() resolves a parameter grid, computes each candidate’s concrete feature set, deduplicates shared indicator fingerprints, and records candidate feature-set hashes.
  4. During ledgr_run() or ledgr_sweep(), the engine computes the registered feature values at each pulse without looking past the current bar.
  5. Strategy code normally reads one feature across the decision axis with ctx$vec$feature(). Scalar accessors remain useful for inspection and for active aliases that have no whole-universe alias accessor yet.

A fingerprint identifies the feature definition, not just the name. If the calculation, parameters, warmup rule, adapter, or selected output changes, the fingerprint changes even when the feature ID stays readable. Feature IDs identify values inside the pulse context. Fingerprints identify the feature definition used to compute those values. For multi-output sources such as TTR BBands or MACD, each selected output is an ordinary indicator with its own feature ID and output-specific fingerprint.

Feature IDs in one map must be unique. An alias does not make two different definitions with the same engine ID safe, and an alias may not equal another entry’s feature ID. It may equal its own feature ID. These fail-closed rules keep scalar engine-ID reads and mapped alias reads from naming different series. An alias never changes the underlying feature ID or fingerprint.

A bundle is an authoring convenience for declaring several indicator outputs at once. The engine receives ordinary single-output feature definitions after the bundle is flattened. The multi-output bundle helper follows the same lifecycle. It is not a second feature system.

The same idea works for crossover rules. An SMA crossover registers two separate indicators: one short moving average and one long moving average. The economic meaning is “fast trend above slow trend” rather than “close above one trend line.” Each moving average has its own feature ID, warmup, and stored values.

crossover_features <- ledgr_feature_map(
  sma_fast = ledgr_ind_sma(10),
  sma_slow = ledgr_ind_sma(30)
)
crossover_ids <- ledgr_feature_id(crossover_features)

ledgr_feature_contracts(crossover_features)
#> # A tibble: 2 x 5
#>   alias    feature_id source requires_bars stable_after
#>   <chr>    <chr>      <chr>          <int>        <int>
#> 1 sma_fast sma_10     ledgr             10           10
#> 2 sma_slow sma_30     ledgr             30           30

In a strategy, the crossover condition is just a comparison of the two mapped aliases after warmup:

crossover_strategy <- function(ctx, params) {
  fast <- ctx$vec$feature(crossover_ids[["sma_fast"]])
  slow <- ctx$vec$feature(crossover_ids[["sma_slow"]])
  ready <- is.finite(fast) & is.finite(slow)

  targets <- ctx$flat()
  targets[ready & fast > slow] <- params$qty
  targets
}

Inspect One Pulse

Create a small sealed snapshot and inspect one decision pulse before running a full backtest. ledgr_pulse_snapshot() builds the ctx a strategy would receive at one timestamp; vignette("strategy-authoring-tools", package = "ledgr") uses it to test strategies. This keeps the runtime data visible before the article introduces more metadata.

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 = "indicators_demo"
)

pulse <- ledgr_pulse_snapshot(
  snapshot,
  universe = c("DEMO_01", "DEMO_02"),
  ts_utc = ledgr_utc("2019-03-01"),
  features = features
)

At this timestamp, ledgr has computed the two features for each instrument in the universe through the same feature path a dense run uses over snapshot history through this timestamp. That includes each declaration’s parameters, stable_after, and, for a custom indicator computed over the whole series, its series_fn (vignette("custom-indicators", package = "ledgr")). The long pulse view shows the result directly: one row per instrument and feature. Without a feature map, alias is NA. With the map, rows are filtered to the mapped features and aliases are filled.

ledgr_pulse_features(pulse, features)
#> # A tibble: 4 x 5
#>   ts_utc              instrument_id feature_id feature_value alias 
#>   <dttm>              <chr>         <chr>              <dbl> <chr> 
#> 1 2019-03-01 00:00:00 DEMO_01       return_5         0.0853  ret_5 
#> 2 2019-03-01 00:00:00 DEMO_01       sma_10          99.8     sma_10
#> 3 2019-03-01 00:00:00 DEMO_02       return_5         0.00402 ret_5 
#> 4 2019-03-01 00:00:00 DEMO_02       sma_10          68.2     sma_10

The wide pulse view is useful for debugging and ML-style row-per-observation workflows. It contains one OHLCV block and one feature block for each instrument. OHLCV columns use {instrument_id}__ohlcv_{field}. Feature columns use {instrument_id}__feature_{feature_id}. A feature map filters and orders feature columns, using aliases as the wide feature keys.

ledgr_pulse_wide(pulse, features)
#> # A tibble: 1 x 17
#>   ts_utc                cash equity DEMO_01__ohlcv_open DEMO_01__ohlcv_high
#>   <dttm>               <dbl>  <dbl>               <dbl>               <dbl>
#> 1 2019-03-01 00:00:00 100000 100000                103.                107.
#> # i 12 more variables: DEMO_01__ohlcv_low <dbl>, DEMO_01__ohlcv_close <dbl>,
#> #   DEMO_01__ohlcv_volume <dbl>, DEMO_01__feature_ret_5 <dbl>,
#> #   DEMO_01__feature_sma_10 <dbl>, DEMO_02__ohlcv_open <dbl>, DEMO_02__ohlcv_high <dbl>,
#> #   DEMO_02__ohlcv_low <dbl>, DEMO_02__ohlcv_close <dbl>, DEMO_02__ohlcv_volume <dbl>,
#> #   DEMO_02__feature_ret_5 <dbl>, DEMO_02__feature_sma_10 <dbl>

ledgr_pulse_features() and ledgr_pulse_wide() work on interactive pulse snapshots and on the ctx object inside an ordinary strategy function. They are inspection views over the same pulse-known data used by ctx$feature() and ctx$features().

Access Features In A Strategy

The long and wide pulse views are useful when you want to inspect the computed data, compare instruments, or think in model-like rows. They are not always the clearest shape for strategy code. A strategy often wants to ask a smaller question: “what are the current values for this instrument?”

That is why ledgr also exposes the same pulse data through scalar and mapped accessors. The table views and the accessors are not competing APIs; they are different views over the same pulse-known data.

The explicit scalar accessor is useful when you want to show or debug one value. It uses the engine ID, not the alias:

ids <- ledgr_feature_id(features)
pulse$feature("DEMO_01", ids[["ret_5"]])
#> [1] 0.08531877

Mapped access returns a named numeric vector keyed by alias for one instrument at one pulse:

x <- pulse$features("DEMO_01", features)
x
#>       ret_5      sma_10 
#>  0.08531877 99.79637070
ledgr_passed_warmup(x)
#> [1] TRUE

Warmup is the period before a known feature has enough prior bars to produce a usable value. A warmup NA is not an unknown feature; it is a known feature that is not ready yet.

Try it

Change the scalar accessor to pulse$feature("DEMO_01", "returns_5"). Why does that fail while ids[["ret_5"]] works?

A strategy decides for every instrument at once. ctx$vec$feature() reads one feature for the whole universe in a single call, one value per instrument in the order of ctx$universe. It takes the engine ID, which is what ids already holds:

strategy <- function(ctx, params) {
  ret_5 <- ctx$vec$feature(ids[["ret_5"]])
  sma_10 <- ctx$vec$feature(ids[["sma_10"]])
  ready <- !is.na(ret_5) & !is.na(sma_10)
  qualifies <- ready & ret_5 > params$min_return & ctx$vec$close > sma_10

  targets <- ctx$flat()
  targets[qualifies] <- params$qty
  targets
}

That pattern keeps the signal logic readable:

  • features is where feature identity and aliases live; ids turns an alias into the engine ID.
  • ready is the warmup gate: an instrument qualifies only once both of its features are usable. For one instrument, ledgr_passed_warmup() asks the same question of the vector ctx$features() returns.
  • The rest of the condition is the economic rule.
  • The strategy still returns ordinary target quantities.

The rule reads the feature vectors by hand because reading them is this section’s lesson. ledgr_signal_feature(ctx, ids[["ret_5"]]) reads the same vector for the helper pipeline that vignette("strategy-development", package = "ledgr") shows.

ctx$features() in a loop over ctx$universe would give the same targets, but it repeats per-instrument work at every pulse. Keep the scalar and mapped accessors for inspecting one instrument.

The strategy reads ids from the code around it, so it closes over a value built from the feature map. The run records the registered feature definitions, but the stored strategy source refers to ids only by name. Keep the code that builds features and ids with your research record; vignette("reproducibility", package = "ledgr") explains why such a strategy is Tier 2.

Run The Example

The experiment registers the indicator objects. ledgr computes those features for every instrument at each pulse, then gives the strategy only the pulse-time values.

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

bt <- exp |>
  ledgr_run(
    params = list(min_return = 0, qty = 10),
    run_id = "indicators_demo_run"
  )
#> 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.

ledgr_results(bt, what = "fills")
#> # A tibble: 39 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-23 2019-01-23 00:00:00    DEMO_01       BUY      10  88.0     0
#>  2         2 2019-01-30 2019-01-30 00:00:00    DEMO_02       BUY      10  71.1     0
#>  3         3 2019-02-01 2019-02-01 00:00:00    DEMO_02       SELL     10  69.3     0
#>  4         4 2019-02-06 2019-02-06 00:00:00    DEMO_01       SELL     10  92.9     0
#>  5         5 2019-02-13 2019-02-13 00:00:00    DEMO_01       BUY      10  93.9     0
#>  6         6 2019-02-19 2019-02-19 00:00:00    DEMO_02       BUY      10  68.7     0
#>  7         7 2019-02-25 2019-02-25 00:00:00    DEMO_02       SELL     10  67.5     0
#>  8         8 2019-03-08 2019-03-08 00:00:00    DEMO_02       BUY      10  68.9     0
#>  9         9 2019-03-11 2019-03-11 00:00:00    DEMO_01       SELL     10 106.      0
#> 10        10 2019-03-11 2019-03-11 00:00:00    DEMO_02       SELL     10  68.0     0
#> # i 29 more rows
#> # i 2 more variables: realized_pnl <dbl>, action <chr>

close(pulse)
close(bt)

The LEDGR_LAST_BAR_NO_FILL warning comes from the last bar: the rule changed its target there, and there is no later open at which to fill it. It is expected at the end of most samples; vignette("execution-semantics", package = "ledgr") explains it.

The fill table is the first execution check: it shows which instruments actually crossed from a target into a position, at which next-open price. An empty table would be a prompt to inspect warmup and signal values before trying more parameters.

Read The Feature Contracts

After you have seen the feature values at a pulse, the contract table is easier to read. The feature contracts are what ledgr will compute for every instrument in the run. alias is for your strategy code. feature_id is the stable engine ID. Warmup metadata tells you when a known feature may still be NA.

ledgr_feature_contracts(features)
#> # A tibble: 2 x 5
#>   alias  feature_id source requires_bars stable_after
#>   <chr>  <chr>      <chr>          <int>        <int>
#> 1 ret_5  return_5   ledgr              6            6
#> 2 sma_10 sma_10     ledgr             10           10

Plain lists remain valid too. For a named list, names become aliases in the contract table. For an unnamed list, alias is NA.

plain_features <- list(ledgr_ind_returns(5), ledgr_ind_sma(10))
ledgr_feature_contracts(plain_features)
#> # A tibble: 2 x 5
#>   alias feature_id source requires_bars stable_after
#>   <chr> <chr>      <chr>          <int>        <int>
#> 1 <NA>  return_5   ledgr              6            6
#> 2 <NA>  sma_10     ledgr             10           10

Parameter Grids Register Every Needed Feature

If a parameter grid changes a lookback, register every lookback variant before the run. ledgr does not create indicators dynamically from params; the run only computes the feature contracts registered on the experiment.

swept_features <- ledgr_feature_map(
  ret_5 = ledgr_ind_returns(5),
  ret_10 = ledgr_ind_returns(10),
  ret_20 = ledgr_ind_returns(20)
)

ledgr_feature_id(swept_features)
#>       ret_5      ret_10      ret_20 
#>  "return_5" "return_10" "return_20"

The feature set must cover the whole grid: lookback = 20 means return_20 must already be registered. A missing feature ID is an unknown-feature error, not warmup. In short, all feature parameter values must be registered before ledgr_run(); do not create ledgr_ind_returns(params$lookback) lazily inside the strategy.

For exploratory sweeps over ledgr-owned indicator parameters, prefer active aliases. Declare the varying constructor argument with ledgr_param(); each sweep candidate then resolves its own concrete indicator behind a stable alias:

active_features <- ledgr_feature_map(
  fast = ledgr_ind_sma(ledgr_param("fast_n")),
  slow = ledgr_ind_sma(ledgr_param("slow_n"))
)

ledgr_snapshot_close(snapshot)

vignette("sweeps", package = "ledgr") composes the feature and strategy grids for this map, runs the sweep and reads the candidate rows.

For single-output indicators, the feature-map alias is the strategy-facing name. Read it once across the decision axis with ctx$vec$feature("alias"); use ctx$features(id) when you deliberately need several mapped values for one instrument. On an axis of at least 100 instruments, an active-map bundle loop warns when its calls reach the axis size and names the plane replacement. Explicit-map bundle calls remain local and unrecorded. There is no plural whole-universe accessor: read each required alias through the singular plane. Bundle entries are intentionally flat; the TTR companion explains why bundles use prefix or naming rather than one outer alias.

For TTR-backed declarations, multi-output bundles, and adapter warmup rules, read vignette("ttr-indicators", package = "ledgr").

Troubleshoot Warmup And Zero Trades

Warmup problems are easiest to diagnose by connecting four facts:

  1. ledgr_feature_contracts(features) tells you how many bars each feature needs before it can produce a usable value.
  2. ledgr_feature_contract_check(snapshot, features) joins those contracts to the actual per-instrument bar counts in the snapshot.
  3. ledgr_pulse_features(pulse, features) shows the current pulse-known values for the instruments and aliases you registered.
  4. summary(bt) prints Warmup Diagnostics when a completed run has registered features that can never become usable for an instrument because available bars are below the feature contract.
warmup_check_snapshot <- ledgr_snapshot_from_df(
  bars |>
    group_by(instrument_id) |>
    filter(instrument_id != "DEMO_02" | row_number() <= 4L) |>
    ungroup(),
  snapshot_id = "indicators_warmup_check"
)

ledgr_feature_contract_check(warmup_check_snapshot, features) |>
  select(alias, instrument_id, available_bars, stable_after, warmup_achievable)
#> # A tibble: 4 x 5
#>   alias  instrument_id available_bars stable_after warmup_achievable
#>   <chr>  <chr>                  <int>        <int> <lgl>            
#> 1 ret_5  DEMO_01                  129            6 TRUE             
#> 2 sma_10 DEMO_01                  129           10 TRUE             
#> 3 ret_5  DEMO_02                    4            6 FALSE            
#> 4 sma_10 DEMO_02                    4           10 FALSE

ledgr_snapshot_close(warmup_check_snapshot)

The warmup_achievable column is FALSE when an instrument does not have enough available bars to satisfy a feature’s stable_after contract.

Normal early warmup is temporary: a feature is NA near the beginning of an instrument’s sample and later becomes finite. Impossible warmup is different: the instrument never has enough available bars for that feature. In that case, zero trades can be a valid completed run plus a useful diagnostic, not a failed run.

Diagnose A Successful Run With Zero Trades

A completed run with zero trades is not automatically wrong. It means ledgr accepted the strategy outputs and the ledger reached the end of the sample, but no fill closed any quantity.

Use this checklist before changing the strategy:

  1. Start with summary(bt). If Closed Trades is zero, win_rate and avg_trade should be NA, not zero. If a registered feature can never become usable because the sample is too short, summary(bt) also prints a Warmup Diagnostics note with the feature ID, instrument ID, required bars, and available bars.
  2. Inspect ledgr_results(bt, what = "fills"). Empty fills mean nothing ever executed. Non-empty fills with empty trades mean positions opened but did not close.
  3. Confirm the feature IDs with ledgr_feature_id(features). A helper such as ledgr_signal_return(ctx, lookback = 60) reads return_60; that indicator must be registered before the run.
  4. Compare feature contracts with sample length before assuming the strategy is broken. ledgr_feature_contracts(features) shows requires_bars and stable_after. If an instrument has fewer available bars than a feature’s requires_bars, that feature cannot become usable for that instrument.
  5. Inspect a late pulse with ledgr_pulse_snapshot(). If the feature is still NA for every instrument near the end of the sample, the issue is no longer ordinary early warmup. Check the lookback length, sample length, universe, and feature registration.
  6. If the helper pipeline returns a ledgr_empty_selection on a late diagnostic pulse, inspect the signal values directly. Ordinary early warmup should have passed by then; a late all-missing signal usually points to sample length, universe, or feature-registration issues.

Timestamp checks should compare normalized UTC values, not local string representations. The chunk is a fragment for use inside a strategy or on a pulse you have built, so it is not executed here:

target_ts <- ledgr_utc("2020-01-05T00:00:00Z")
ledgr_utc(ctx$ts_utc) == target_ts

intraday_time <- format(ledgr_utc(ctx$ts_utc), "%H:%M:%S", tz = "UTC")
intraday_time >= "14:30:00" && intraday_time <= "21:00:00"

If fills are empty, distinguish zero signals from zero sizing. In your own run, use the same snapshot, universe, timestamp, features, strategy, and params you are debugging. The ... placeholders stand for those values, so this fragment is not executed here:

pulse <- ledgr_pulse_snapshot(
  snapshot,
  universe = ...,
  ts_utc = ...,
  features = features
)
target <- strategy(pulse, params)
target
all(target == 0)

Custom fill-model contract errors are different from zero-trade outcomes; see the fill-model reference documentation for the required fill fields.

Three Warmup-Adjacent Cases

Warmup is per instrument. One instrument can have a usable value while another is still NA because it has fewer bars or a different data history.

Ordinary feature warmup is local to the beginning of each instrument’s usable sample. A known feature is NA for early pulses, then becomes usable once the feature contract has enough bars.

Impossible warmup is different: every value for an instrument/feature remains NA because the instrument never reaches the feature contract. That is the case reported by the Warmup Diagnostics note in summary(bt).

Current-bar absence is a third failure mode. If ledgr cannot construct the pulse sequence because a current bar is absent for an instrument in the requested universe, the run fails before strategy evaluation for that incomplete pulse; this is a pulse construction error, not a feature warmup value.

Expected Sessions And Certified Gap Behavior

The dense lifecycle above is the entrance. Availability-aware research adds a stricter question: which sessions should have contained an observation? A missing observation on a declared open session is a gap. By default the experiment’s history policy carries each price field for up to five expected sessions; ledgr_history_policy(price_carry = NULL) keeps the strict rule, under which the gap invalidates every bounded window that contains it. Return- and volatility-family indicators never use a carried price, and ledgr never calls a gap ordinary startup warmup.

strict_smas <- ledgr_feature_map(
  built_in = ledgr_ind_sma(2),
  public_ttr = ledgr_ind_ttr("SMA", input = "close", n = 2),
  custom = ledgr_indicator(
    "custom_sma_2",
    function(window) mean(window$close),
    requires_bars = 2,
    gap_contract = "strict_window"
  )
)

ledgr_feature_contracts(strict_smas)
#> # A tibble: 3 x 5
#>   alias      feature_id   source requires_bars stable_after
#>   <chr>      <chr>        <chr>          <int>        <int>
#> 1 built_in   sma_2        ledgr              2            2
#> 2 public_ttr ttr_sma_2    TTR                2            2
#> 3 custom     custom_sma_2 custom             2            2

In the strict-gap table in vignette("missing-data-and-sessions", package = "ledgr"), which disables price carry, all three follow the same NA, 11, NA, NA, 15, 17 availability path. That is session equivalence, not a claim that different formulas are numerically identical.

Form Declaration Availability-aware calculation
Built-in SMA, EMA, RSI or returns ledgr_ind_sma(n), ledgr_ind_ema(n), ledgr_ind_rsi(n), ledgr_ind_returns(n) Supported: bounded
Registered TTR form ledgr_ind_ttr() on a catalogued function, output and argument form Supported: per catalogue row
TTR output bundle ledgr_ind_ttr_outputs(...) Supported: per catalogue row
Custom bounded declaration ledgr_indicator(..., history_class = "bounded") or gap_contract = "strict_window" Supported: bounded
Undeclared custom or unregistered TTR form ledgr_indicator(...) without a history declaration, or an uncatalogued TTR form Supported: history-dependent

A bounded indicator uses the last requires_bars expected sessions and is missing while that window is incomplete. A history-dependent indicator runs once per contiguous segment of available input and restarts, and warms up again, after a gap. Contradictory declarations refuse with ledgr_indicator_history_contradiction.

A delisting ends carry and windows: after each relisting, price carry never reaches back across the terminal interval, and a bounded indicator restarts, so it needs requires_bars sessions from the relisting before it is ready again and never averages prices from before the delisting with prices after it.

stable_after is the first-valid boundary: the first session after a start or a restart at which ledgr exposes a value. It is not a convergence promise. A history-dependent indicator that restarts after a gap is valid again once it passes stable_after, yet it can still differ from the value an uninterrupted series would have produced. In ledgr’s conformance measurements (TTR 0.24.4, one missing session at three positions), the restarted series needed 40 to 47 observations to come within a scaled 1e-6 of the uninterrupted series for TTR EMA with n = 10, 59 to 72 for the MACD outputs with nFast = 5, nSlow = 10 and nSig = 4, 101 to 109 for ATR with n = 10, and 157 to 190 for RSI with n = 14. Read those ranges as evidence about how long retained state takes to recover, not as a guarantee.

Before an availability-aware run, sweep or precompute uses its features, ledgr checks each definition for look-ahead at no more than four fixed positions on one instrument: the value recomputed from the history ending at a position must equal the prepared value, or preparation refuses with ledgr_feature_lookahead_detected. The check is a sample, so passing it does not prove that a definition is causal, and dense experiments do not run it. vignette("custom-indicators", package = "ledgr") describes the extra check for custom definitions that supply both fn and series_fn.

For result-table interpretation after a zero-trade run, read vignette("metrics-and-accounting", package = "ledgr").

Where Next