Custom indicators are ledgr’s extension point for derived market data. They are useful when the built-in indicators and TTR-backed indicators do not express the feature you want.
They are also the highest-risk feature boundary. A custom indicator can keep a strategy pulse-safe, or it can hide future information in an ordinary-looking feature value. This article explains the authoring contract.
Custom features are a leakage boundary
ledgr can validate 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 used in ctx$feature(instrument_id, 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". |
Use params for intentional configuration. Do not close over mutable session objects when the value should be part of the feature definition.
flowchart LR declare["declare<br/>indicator"] register["register<br/>experiment"] compute["compute<br/>pulse values"] read["read<br/>ctx feature"] target["return<br/>target holdings"] declare --> register --> compute --> read --> target
Scalar Indicators
The scalar path is the simplest contract:
range_3 <- ledgr_indicator(
id = "range_3",
fn = function(window, params) {
mean(window$high - window$low)
},
requires_bars = 3,
stable_after = 3,
params = list()
)The engine calls fn(window, params) on a bounded historical window ending at the current bar. Before stable_after, ledgr returns NA_real_ for that feature. After warmup, the scalar result must be one finite numeric value.
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.
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)
)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.
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_.
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, requires_bars, stable_after, and deterministic params.
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:
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 = paste0("custom-indicators-", Sys.getpid())
)
features <- list(range_3)
strategy <- function(ctx, params) {
targets <- ctx$flat()
for (id in ctx$universe) {
value <- ctx$feature(id, "range_3")
if (is.finite(value) && value < params$max_range) {
targets[id] <- params$qty
}
}
targets
}
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, ctx$feature(id, "range_3") reads the exact feature ID from the pulse context for one instrument. Unknown feature IDs fail loudly. Warmup for a known feature is represented by NA_real_.
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 = 5, qty = 10),
run_id = paste0("custom-indicators-run-", Sys.getpid())
)
#> Warning: no DISPLAY variable so Tk is not available
summary(custom_bt)
#> ledgr Backtest Summary
#> ======================
#>
#> Performance Metrics:
#> Total Return: 0.49%
#> Annualized Return: 2.96%
#> Max Drawdown: -0.89%
#>
#> Risk Metrics:
#> Risk-Free Rate: 0.00% annual
#> Annualization: 252 periods/year (US equity daily)
#> Volatility (annual): 2.25%
#> Sharpe Ratio: 1.305
#>
#> Trade Statistics:
#> Total Trades: 0
#> Win Rate: N/A (no trades)
#> Avg Trade: N/A (no trades)
#>
#> Exposure:
#> Time in Market: 93.02%
ledgr_results(custom_bt, what = "fills")
#> # A tibble: 2 × 9
#> event_seq ts_utc instrument_id side qty price fee realized_pnl action
#> <int> <date> <chr> <chr> <dbl> <dbl> <dbl> <dbl> <chr>
#> 1 1 2019-01-04 DEMO_01 BUY 10 90.7 0 0 OPEN
#> 2 2 2019-01-04 DEMO_02 BUY 10 74.7 0 0 OPEN
ledgr_results(custom_bt, what = "trades")
#> # A tibble: 0 × 9
#> # ℹ 9 variables: event_seq <int>, ts_utc <date>, instrument_id <chr>, side <chr>,
#> # qty <dbl>, price <dbl>, fee <dbl>, realized_pnl <dbl>, action <chr>The 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
Change max_range from 5 to 2 in the run params. Which fills disappear, and why does the custom indicator ID stay the same?
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("indicators", package = "ledgr")covers the built-in feature lifecycle and pulse-time accessors. -
vignette("ttr-and-adapter-indicators", package = "ledgr")covers TTR-backed indicators and multi-output bundles. -
vignette("leakage", package = "ledgr")explains why custom feature code is a causal review boundary.