Custom indicators are ledgr’s extension point for derived market data. By the end of this article you will have written the safest scalar form, registered it, read it as a whole-universe strategy vector, and identified when the optional vectorized and external-data paths need extra review. You need it when no built-in or TTR indicator computes what your rule reads.
They are also the highest-risk feature boundary. A custom indicator can keep a strategy pulse-safe, or hide future information in an ordinary-looking value.
Custom features are a leakage boundary
ledgr validates shape, warmup, fingerprints, and registration. It cannot prove that externally authored feature logic avoided future information.
The Indicator Object
A custom indicator is a declared feature computation with a stable feature ID, deterministic params, warmup rules, and one or two functions that produce pulse-known values. ledgr_indicator() creates a feature definition. The important fields are:
| Field | Meaning |
|---|---|
id |
Stable feature ID a strategy reads with ctx$vec$feature(feature_id). |
fn |
Scalar function for one bounded historical window. |
series_fn |
Optional vectorized function for one instrument’s full bar series. |
requires_bars |
Minimum lookback requirement for the indicator definition. |
stable_after |
First row where the output is considered usable. |
params |
Named deterministic parameter list included in the fingerprint. |
source |
Source label: "ledgr", "TTR", or "custom". |
gap_contract |
Optional claim that a bounded window remains valid under declared-session gaps. |
Use params for intentional configuration. Do not close over mutable session objects when the value should be part of the feature definition.
Scalar Indicators
The scalar path is the simplest contract:
range_3 <- ledgr_indicator(
id = "range_3",
fn = function(window) {
mean(window$high - window$low)
},
requires_bars = 3,
stable_after = 3,
params = list(),
gap_contract = "strict_window"
)The scalar contract supports both fn(window) and fn(window, params). ledgr calls the one-argument form with a bounded historical window ending at the current bar. When the function declares a second argument, ledgr also supplies the indicator’s deterministic params. Before stable_after, ledgr returns NA_real_ for that feature. After warmup, the scalar result must be one finite numeric value. Put configuration in params so it is part of feature identity; use the two-argument form when the calculation needs that configuration.
This path is easy to reason about because the function receives only historical rows up to the current decision point. It is the right first implementation for most custom features.
gap_contract = "strict_window" (or history_class = "bounded") is a substantive claim: on an availability-aware run, the function receives its last requires_bars expected sessions and is valid only when each of them has an observation, possibly carried by the history policy. Without a history declaration ledgr treats a custom indicator as history-dependent: it runs once per contiguous segment of available input and restarts after a gap. ledgr never infers that a definition is bounded.
Vectorized Indicators
series_fn is the fast path for indicators that are naturally computed over a whole series. When both fn and series_fn are supplied, the engine uses series_fn for full-series feature computation and keeps fn as the scalar definition for the same feature. They should be equivalent after warmup.
sma_3_custom <- ledgr_indicator(
id = "sma_3_custom",
fn = function(window, params) {
mean(utils::tail(window$close, params$n))
},
series_fn = function(bars, params) {
stats::filter(
bars$close,
rep(1 / params$n, params$n),
sides = 1
) |>
as.numeric()
},
requires_bars = 3,
stable_after = 3,
params = list(n = 3)
)
ledgr_feature_contracts(list(sma_3_custom))
#> # A tibble: 1 x 5
#> alias feature_id source requires_bars stable_after
#> <chr> <chr> <chr> <int> <int>
#> 1 <NA> sma_3_custom custom 3 3The contract row is what a strategy relies on: sma_3_custom is the ID to read, source records that it is your code, and the feature is NA until the third bar. The alias is NA because the list is unnamed.
The series_fn(bars, params) contract is strict:
-
barscontains one instrument’s bars in ascending timestamp order; - the return value must be an atomic numeric vector;
- the return length must equal
nrow(bars); - output position
ibelongs to input rowi; - warmup rows before
stable_afterare normalized toNA_real_; - post-warmup
NA,NaN, and infinite values are errors.
The example uses sides = 1, so row i is computed from row i and earlier rows only. That is the causal alignment a vectorized feature must preserve.
Output validation proves shape and value validity. It does not prove causal correctness. Because series_fn receives the full bar series, a badly written vectorized function can still use future rows internally while returning a correctly shaped vector.
Availability-aware experiments add two sampled checks before any run, sweep candidate or precompute payload uses the feature. For every definition, ledgr picks the instrument with the most calculated values and at most four fixed sessions on it, recomputes the feature from the history ending at each session, and refuses with ledgr_feature_lookahead_detected when that value differs from the prepared one. For a custom definition declared bounded that supplies both fn and series_fn, ledgr also calls fn on its window at the same sessions and refuses with ledgr_indicator_gap_parity when it disagrees with series_fn beyond the declared tolerance (exact by default). The checks are samples: a leak at other sessions, or one that reads data from outside the bars, can pass them. Dense experiments do not run them.
That is why scalar fn is often the safer first version. Add series_fn when the feature logic is stable and the alignment is obvious.
Warmup And Stability
requires_bars and stable_after are related but not identical.
requires_bars says how much history the indicator definition needs. stable_after says when the output is usable in the feature series. It must be greater than or equal to requires_bars.
For a three-bar moving average, both are usually 3. For indicators with a longer settling period, stable_after can be larger. ledgr treats rows before stable_after as warmup and exposes them as NA_real_. stable_after is the first valid row, not a promise that a recursive indicator has converged.
Warmup NA is expected. Post-warmup NA, NaN, or infinite values mean the feature did not satisfy its contract.
Fingerprints
Indicator definitions are fingerprinted so runs can later verify that the registered feature definition still matches the one recorded with the run.
The fingerprint includes the feature ID, scalar function, vectorized function when present, deterministic direct captures of those functions, requires_bars, stable_after, and deterministic params. Atomic values and deterministic lists can be direct captures; environments, connections, and other unsupported session objects refuse as non-deterministic. A captured function contributes its own function identity, but ledgr does not recursively include that helper function’s captures. Put explicitly varying research inputs in params so their identity stays visible.
Fingerprints are an identity check, not a semantic proof. They help ledgr answer “is this the same feature definition?” They do not prove that a custom series_fn avoided lookahead or that an external data source was historically available at the simulated decision time.
Deterministic Parameters And Unsafe Calls
params must be a named list of deterministic values. Use strings for dates and timestamps when they are part of the feature definition. Do not pass open connections, environments, external pointers, or live session objects.
Indicator functions are checked for common unsafe patterns. Examples include:
- global assignment with
<<-; - wall-clock calls such as
Sys.time()andSys.Date(); - randomness such as
runif(),rnorm(), andsample(); - dynamic lookup and execution helpers such as
get(),eval(), andassign(); - environment reads such as
Sys.getenv().
These checks are guardrails. They do not replace careful review of the feature logic.
Adapter Helpers
ledgr_adapter_r() wraps a function that operates on the close series of the bounded window. It is useful for simple package or base R functions:
median_close <- ledgr_adapter_r(
stats::median,
id = "median_close_5",
requires_bars = 5
)The adapter stores the adapted function identity and arguments in indicator parameters. It still creates an ordinary ledgr_indicator.
ledgr_adapter_csv() adapts a CSV of precomputed values. The chunk is not executed because the file it names does not exist in the package build:
csv_indicator <- ledgr_adapter_csv(
"features/my_values.csv",
value_col = "my_value",
id = "my_value"
)The CSV must identify timestamp, instrument, and value columns. This is useful for external feature pipelines, but it moves availability discipline outside ledgr. The CSV values must already respect the simulated decision times. ledgr can hash and look up the values; it cannot know whether the upstream pipeline used future information.
Register And Read
Custom indicators are registered with the experiment just like built-in indicators:
bars <- ledgr_demo_bars |>
filter(
instrument_id %in% c("DEMO_01", "DEMO_02"),
between(
ts_utc,
ledgr_utc("2019-01-01"),
ledgr_utc("2019-02-28")
)
)
snapshot <- ledgr_snapshot_from_df(
bars,
snapshot_id = "custom_indicators_demo"
)
features <- list(range_3)
strategy <- function(ctx, params) {
narrow <- ledgr_signal_feature(ctx, "range_3") < params$max_range
ctx |>
ledgr_selection(where = narrow, missing = "exclude") |>
ledgr_target_quantity(ctx, params$qty)
}
exp <- ledgr_experiment(
snapshot = snapshot,
strategy = strategy,
features = features,
opening = ledgr_opening(cash = 10000),
cost_model = ledgr_cost_zero()
)
ledgr_feature_id(features)
#> [1] "range_3"Inside the strategy, ledgr_signal_feature(ctx, "range_3") reads the exact feature ID for the whole decision axis in one call. Unknown IDs fail loudly; warmup for a known feature is NA_real_, which missing = "exclude" leaves unselected.
Run the experiment and inspect the event-derived result tables just as you would for built-in indicators:
custom_bt <- ledgr_run(
exp,
params = list(max_range = 1.5, qty = 10),
run_id = "custom-indicators-run"
)
strict_bt <- ledgr_run(
exp,
params = list(max_range = 1.2, qty = 10),
run_id = "custom-indicators-strict"
)
custom_bt
#> ledgr Backtest Results
#> ======================
#>
#> Run ID: custom-indicators-run
#> Period: 2019-01-01 to 2019-02-28
#> Opening Cash: $10000.00
#> Final Equity: $10054.88
#> Total Return: 0.55%
#> Max Drawdown: -0.65%
#> 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
tibble(
max_range = c(1.5, 1.2),
fills = c(
nrow(ledgr_results(custom_bt, what = "fills")),
nrow(ledgr_results(strict_bt, what = "fills"))
)
)
#> # A tibble: 2 x 2
#> max_range fills
#> <dbl> <int>
#> 1 1.5 23
#> 2 1.2 21The custom feature only changes how pulse-known values are computed. It does not change the strategy return contract, fill model, ledger, result tables, or metric workflow.
Try it
Inspect the two runs’ fill tables. Which instruments lose entries when max_range falls from 1.5 to 1.2, and why does the feature ID stay the same while the strategy decision changes?
What To Remember
Custom indicators let external feature logic enter ledgr’s deterministic pulse engine. Keep the boundary explicit:
- prefer scalar
fnuntil the logic is stable; - add
series_fnonly when full-series alignment is clear; - treat
series_fnand CSV adapters as review points for leakage; - keep all intentional configuration in deterministic
params; - register every feature before
ledgr_run(); - use
ledgr_feature_id()to confirm the exact ID a strategy should read.
Where Next
-
vignette("corporate-action-adapter-authoring", package = "ledgr")writes an adapter for vendor corporate-action records. -
vignette("indicators", package = "ledgr")covers the built-in feature lifecycle and pulse-time accessors. -
vignette("leakage", package = "ledgr")explains why custom feature code is a causal review boundary.