Skip to contents

vignette("strategy-development", package = "ledgr") showed the contract: a strategy is a function of ctx and params that returns the holdings you want after the next fill. That is enough to run a first idea. It is not yet enough to trust one. Before you spend a backtest on a strategy, you want to see what it decides on a single day, know exactly how a ranking became a number of shares, choose what happens when an input is missing, and sometimes let today’s decision depend on yesterday’s.

This article builds one momentum strategy three times. Each version fixes a problem the previous one showed:

  1. rank the instruments and hold the top two;
  2. hold only the instruments that are actually rising;
  3. rebalance once a week and keep a small piece of decision state.

By the end you will have tested each version on one pulse, checked its share sizing by hand, run all three, and compared how often they trade. Read it once your strategy ranks or sizes instruments, or needs to remember something between decisions.

Set Up

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

The examples use four instruments from the offline demo data for the first half of 2019.

instruments <- c("DEMO_01", "DEMO_02", "DEMO_03", "DEMO_04")

bars <- ledgr_demo_bars |>
  filter(
    instrument_id %in% instruments,
    between(ts_utc, ledgr_utc("2019-01-01"), ledgr_utc("2019-06-30"))
  ) |>
  mutate(ts_utc = ts_utc + 16 * 60 * 60)

The weekly example later in the article needs an authoritative venue-session axis. The fixture derives and declares that calendar here. The setup is folded because vignette("point-in-time-inputs", package = "ledgr") owns the topic, but it remains visible and copyable so the snapshot code below is complete.

Show session-calendar setup
bar_times <- sort(unique(bars$ts_utc))
bar_dates <- as.Date(bar_times, tz = "UTC")
session_dates <- seq(min(bar_dates), max(bar_dates), by = "day")
close_seconds <- tapply(as.numeric(bar_times), as.character(bar_dates), max)
session_close <- as.POSIXct(
  unname(close_seconds[match(as.character(session_dates), names(close_seconds))]),
  origin = "1970-01-01",
  tz = "UTC"
)
is_open <- !is.na(session_close)
session_open <- session_close - 6 * 60 * 60
session_rows <- data.frame(
  session_date = session_dates,
  status = ifelse(is_open, "open", "closed"),
  session_open = session_open,
  session_close = session_close
)
demo_session_facts <- ledgr_facts(
  ledgr_facts_sessions(
    session_rows,
    venue_id = "DEMO_VENUE",
    knowledge = "assume_effective",
    timezone = "UTC"
  )
)
snapshot <- ledgr_snapshot_from_df(
  bars,
  snapshot_id = "strategy_authoring_snapshot",
  facts = demo_session_facts,
  price_basis = "split_adjusted"
)

features <- list(ledgr_ind_returns(5), ledgr_ind_sma(10))
ledgr_feature_id(features)
#> [1] "return_5" "sma_10"

The strategies read two registered features: the 5-day return, used as momentum, and the 10-day simple moving average, used as a trend line. Features are declared on the experiment, never inside the strategy. vignette("indicators", package = "ledgr") covers feature IDs, warmup, and readable aliases.

Test A Strategy On One Pulse

The first version is the plainest momentum rule: every day, hold the two instruments with the highest 5-day return, using half of the account.

top_momentum <- function(ctx, params) {
  weights <- ctx |>
    ledgr_signal_return(lookback = 5) |>
    ledgr_select_top_n(params$n) |>
    ledgr_weight_equal()

  weights |>
    ledgr_target_rebalance(ctx, equity_fraction = params$invested)
}

params <- list(n = 2, invested = 0.5)

The next section takes that pipeline apart. First, check what it decides. ledgr_pulse_snapshot() builds an inspection context for one timestamp. Its cash, positions, and equity follow the same rules as the ctx passed to the strategy; equity is cash plus the current value of the supplied positions. You can therefore call the strategy yourself. Start by looking at the inputs:

pulse <- ledgr_pulse_snapshot(
  snapshot,
  universe = instruments,
  ts_utc = ledgr_utc("2019-03-12 16:00:00"),
  features = features
)

tibble(
  instrument_id = pulse$universe,
  close = pulse$vec$close,
  momentum = pulse$vec$feature("return_5"),
  trend = pulse$vec$feature("sma_10")
)
#> # A tibble: 4 x 4
#>   instrument_id close momentum trend
#>   <chr>         <dbl>    <dbl> <dbl>
#> 1 DEMO_01       106.   -0.0123 106.
#> 2 DEMO_02        67.7   0.0359  67.0
#> 3 DEMO_03        81.8  -0.0114  83.1
#> 4 DEMO_04       107.   -0.0238 109.

Each ctx$vec value is a plain vector in the order of ctx$universe, which is why the four columns line up. Now ask the strategy for its decision:

top_momentum(pulse, params)
#> <ledgr_target> [4 assets]
#> origin: return_5
#> non-NA: 4/4
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0     369     305       0

On 12 March the strategy wants DEMO_02 and DEMO_03, the two highest 5-day returns, and nothing else. The pulse computes every declared feature through the same feature path used by this fixture’s availability-aware run, so the backtest sees these same values at this timestamp. The share counts depend on the account, which here is the pulse’s default of 100,000 in cash. Calling a strategy on a pulse is the fastest way to catch a wrong feature ID, a reversed comparison, or an unexpected position size before you run months of bars.

Read whole vectors, not one instrument at a time

ctx$vec$close and ctx$vec$feature() return one value per instrument in a single read. The scalar forms ctx$close(id), ctx$feature(id, feature_id), and ctx$position(id) are for an instrument you have already singled out. Calling them in a loop over ctx$universe repeats per-instrument work at every pulse and is the most common way to make a strategy slow. Singular feature reads accept either an active alias or a concrete feature ID. For universes of at least 100 instruments, a loop over the bar, price, position, scalar feature, or active-map bundle accessor triggers one ledgr_scalar_accessor_loop warning per run when its calls reach the axis size and names the vector replacement; the warning changes no result. Read each required active alias once with ctx$vec$feature("alias"). Explicit-map bundle calls stay unrecorded because the plane does not accept an explicit map.

From Scores To Shares

top_momentum() is a chain of small objects. Build it on the pulse one step at a time:

signal <- ledgr_signal_return(pulse, lookback = 5)
signal
#> <ledgr_signal> [4 assets]
#> origin: return_5
#> non-NA: 4/4
#>     DEMO_01     DEMO_02     DEMO_03     DEMO_04
#> -0.01229707  0.03585798 -0.01136937 -0.02381160

feature_signal <- ledgr_signal_feature(pulse, "return_5")
identical(as.numeric(signal), as.numeric(feature_signal))
#> [1] TRUE

selection <- ledgr_select_top_n(signal, n = 2)
selection
#> <ledgr_selection> [4 assets]
#> origin: return_5
#> 2 selected
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>   FALSE    TRUE    TRUE   FALSE

weights <- ledgr_weight_equal(selection)
weights
#> <ledgr_weights> [2 assets]
#> origin: return_5
#> non-NA: 2/2
#> DEMO_02 DEMO_03
#>     0.5     0.5

ledgr_signal_return() reads the registered return_5 feature; it never registers a feature for you. The executed comparison shows the generic ledgr_signal_feature() route for another registered feature. Use ledgr_signal(ctx, values = ...) only when you have already transformed the values into a genuinely custom score.

Object What it holds Made by
signal one score per instrument ledgr_signal_return(), ledgr_signal_feature()
selection TRUE or FALSE: which instruments are chosen ledgr_select_top_n(), ledgr_selection()
weights each chosen instrument’s share of the budget ledgr_weight_equal(), ledgr_weights()
target whole-share quantities for every instrument ledgr_target_rebalance()

Only the target is something ledgr executes. The other three are working objects: returning a signal, selection, or weights from a strategy is an error.

A ranking can come up short. When fewer than n scores are usable, ledgr_select_top_n() selects what it can and warns, so an unexpectedly short ranking is visible. Opt out only when selecting fewer names is an intended policy:

short_signal <- ledgr_signal(c(
  DEMO_01 = 0.02,
  DEMO_02 = NA_real_,
  DEMO_03 = NA_real_,
  DEMO_04 = NA_real_
))

tryCatch(
  ledgr_select_top_n(short_signal, n = 2),
  warning = conditionMessage
)
#> [1] "Only 1 available signal value(s); selecting all available instruments. If this short selection is intentional, use `partial = \"allow\"`."
ledgr_select_top_n(short_signal, n = 2, partial = "allow")
#> <ledgr_selection> [4 assets]
#> 1 selected
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>    TRUE   FALSE   FALSE   FALSE

Without the opt-out the call warns; the chunk catches the warning and shows its message, which says every available instrument is selected and names the opt-out. With partial = "allow", the same selection, DEMO_01, is made silently.

Weights are relative. 0.5 means “half of the budget”, not a number of shares. The weights say how to divide a budget. They do not contain the budget, current prices, or current holdings. Turning them into shares therefore needs the current ctx again:

target <- weights |>
  ledgr_target_rebalance(pulse, equity_fraction = 0.5)
target
#> <ledgr_target> [4 assets]
#> origin: return_5
#> non-NA: 4/4
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0     369     305       0

You can reproduce that sizing yourself. Each chosen instrument receives weight * equity_fraction * equity in cash, divided by its current close and rounded down to whole shares:

chosen <- names(weights)
chosen_close <- pulse$vec$close[match(chosen, pulse$universe)]

tibble(
  instrument_id = chosen,
  budget = as.numeric(weights) * 0.5 * pulse$equity,
  close = chosen_close,
  shares = floor(budget / chosen_close),
  target = as.numeric(target[chosen])
)
#> # A tibble: 2 x 5
#>   instrument_id budget close shares target
#>   <chr>          <dbl> <dbl>  <dbl>  <dbl>
#> 1 DEMO_02        25000  67.7    369    369
#> 2 DEMO_03        25000  81.8    305    305

The remainder left by rounding stays in cash. Every instrument that was not chosen gets a target of zero, so a rebalance sells anything it no longer wants.

Keep A Position While Rebalancing The Rest

Sometimes you want to rebalance around a position you already own. Name it in keep: ledgr keeps its current quantity, reserves its value once, and sizes the new weights from the capital that remains. This pulse holds four shares of DEMO_04 and has 600 in cash. Keeping DEMO_04 leaves that 600 for the new position. This is a concise hold-then-edit pattern: retain the named holding, then size the rest of the portfolio around it.

keep_pulse <- ledgr_pulse_snapshot(
  snapshot,
  universe = instruments,
  ts_utc = ledgr_utc("2019-03-12 16:00:00"),
  features = features,
  cash = 600,
  positions = c(DEMO_04 = 4)
)
keep_weights <- ledgr_weights(c(DEMO_02 = 1), universe = instruments)
keep_weights |>
  ledgr_target_rebalance(keep_pulse, keep = "DEMO_04")
#> <ledgr_target> [4 assets]
#> non-NA: 4/4
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0       8       0       4

DEMO_04 keeps its four shares. DEMO_02 gets eight shares at about 67.7, which uses about 542 of the 600; the rest stays in cash.

equity_fraction applies to that remaining capital. Do not shrink it by hand to make room for the kept position; doing both would shrink the remaining budget a second time and underinvest the account.

Run The First Version

top_momentum_run <- ledgr_experiment(
  snapshot = snapshot,
  strategy = top_momentum,
  features = features,
  opening = ledgr_opening(cash = 10000),
  cost_model = ledgr_cost_zero()
) |>
  ledgr_run(params = params, run_id = "v1_ranking")

top_momentum_fills <- ledgr_results(top_momentum_run, what = "fills")
nrow(top_momentum_fills)
#> [1] 136

top_momentum_fills |>
  select(ts_utc, instrument_id, side, qty) |>
  slice_head(n = 6)
#> # A tibble: 6 x 4
#>   ts_utc              instrument_id side    qty
#>   <dttm>              <chr>         <chr> <dbl>
#> 1 2019-01-09 10:00:00 DEMO_02       BUY      33
#> 2 2019-01-09 10:00:00 DEMO_03       BUY      30
#> 3 2019-01-10 10:00:00 DEMO_02       SELL     33
#> 4 2019-01-10 10:00:00 DEMO_04       BUY      25
#> 5 2019-01-11 10:00:00 DEMO_02       BUY      33
#> 6 2019-01-11 10:00:00 DEMO_03       SELL     30

Nothing trades during the first week. Until five prior bars exist, every 5-day return is NA; ranking skips missing scores, finds nothing to choose, and returns an empty selection without a warning, so the target is all zeros. After warmup, the strategy trades almost every day because the two highest returns keep changing. Each decision fills at the next open, so a fill’s date is the pulse after the decision.

If a whole run finishes with no fills, test the strategy on a late pulse. A feature that never warms up looks exactly like this first week, only it never ends.

The run also prints two LEDGR_LAST_BAR_NO_FILL warnings, one for each instrument whose target changed on the final bar: there is no later open at which to fill those changes. Expect them at the end of most samples; ?LEDGR_LAST_BAR_NO_FILL explains when they matter.

Choose Members With A Rule Instead Of A Rank

The pulse test already showed a weakness. On 12 March the first version bought DEMO_03 even though its 5-day return was negative and its price was below its 10-day average. A ranking always picks two names, even when both are falling.

A better rule: hold every instrument that is rising, meaning positive momentum and a close above its trend, and split the budget equally among however many qualify. That is a yes-or-no decision per instrument, so it goes into ledgr_selection(ctx, where = ...) instead of a ranking. Written directly, the rule fails at the start of the sample:

first_pulse <- ledgr_pulse_snapshot(
  snapshot,
  universe = instruments,
  ts_utc = min(bars$ts_utc),
  features = features
)

rising <- first_pulse$vec$feature("return_5") > 0 &
  first_pulse$vec$close > first_pulse$vec$feature("sma_10")
rising
#> [1] NA NA NA NA

tryCatch(
  ledgr_selection(first_pulse, where = rising),
  error = conditionMessage
)
#> [1] "`where` contains missing decisions for current members: DEMO_01, DEMO_02, DEMO_03, DEMO_04. Use `missing = \"exclude\"` to treat them as not selected; a rebalance then targets those instruments to zero."

At the first pulse neither feature has enough history, so every comparison is NA. ledgr refuses a missing decision instead of quietly treating it as FALSE, because “not chosen” and “could not tell” are different statements.

Ranking treated missing values differently. In top_momentum() a missing score was simply left out of the ranking. A missing number means “no score”; a missing TRUE/FALSE means “no decision”, and the strategy has to resolve it. Here, “not rising” is the right answer when any input is missing, including the close:

trend_momentum <- function(ctx, params) {
  momentum <- ctx$vec$feature("return_5")
  trend <- ctx$vec$feature("sma_10")
  close <- ctx$vec$close
  rising <- momentum > 0 & close > trend

  weights <- ctx |>
    ledgr_selection(where = rising, missing = "exclude") |>
    ledgr_weight_equal()

  weights |>
    ledgr_target_rebalance(ctx, equity_fraction = params$invested)
}

trend_momentum(first_pulse, params)
#> <ledgr_target> [4 assets]
#> non-NA: 4/4
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0       0       0       0
trend_momentum(pulse, params)
#> <ledgr_target> [4 assets]
#> non-NA: 4/4
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0     739       0       0

The first pulse now selects nothing. missing = "exclude" is the deliberate policy: an unknown decision counts as not selected. On 12 March the rule keeps only DEMO_02 and gives it the whole invested half of the account.

trend_momentum_run <- ledgr_experiment(
  snapshot = snapshot,
  strategy = trend_momentum,
  features = features,
  opening = ledgr_opening(cash = 10000),
  cost_model = ledgr_cost_zero()
) |>
  ledgr_run(params = params, run_id = "v2_rule")

nrow(ledgr_results(trend_momentum_run, what = "fills"))
#> [1] 177

The rule trades even more than the ranking did. It stopped buying falling instruments, but every time an instrument starts or stops rising, the equal split changes, and the rebalance resizes every position to its new share, often by a share or two. The third version deals with that churn.

Decide What A Missing Input Should Do

Both versions go flat while their inputs warm up. That was harmless above because the account started in cash. It is not harmless when you start with existing holdings:

holding_pulse <- ledgr_pulse_snapshot(
  snapshot,
  universe = instruments,
  ts_utc = min(bars$ts_utc),
  features = features,
  positions = c(DEMO_01 = 0, DEMO_02 = 40, DEMO_03 = 0, DEMO_04 = 0)
)

trend_momentum(holding_pulse, params)
#> <ledgr_target> [4 assets]
#> non-NA: 4/4
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0       0       0       0
holding_pulse$hold()
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0      40       0       0

trend_momentum() returns zero for DEMO_02, which would sell all 40 shares on the first day only because the features have not warmed up. ctx$hold() returns the current quantities, which means “change nothing”. What a strategy returns for an instrument is always a quantity to hold after the next fill:

The strategy returns Meaning
the current quantity, as ctx$hold() does keep the position
zero, including an instrument left out of the selection sell down to zero
a positive number hold that many shares

These are requests. If the experiment has a risk_chain, it still applies, so returning ctx$hold() does not guarantee that no fill occurs.

If missing input should pause the strategy rather than empty it, say so explicitly. ledgr_passed_warmup() is TRUE only when every value it is given is present, so the guard is one line at the top of the strategy:

if (!ledgr_passed_warmup(ctx$vec$feature("sma_10"))) return(ctx$hold())

The third version uses it.

Schedule Decisions And Keep Strategy Memory

trend_momentum() still decides every day. Suppose you want to reconsider the portfolio only once a week. Keep that timing rule outside the strategy: ledgr can resolve it once against the snapshot’s open-session calendar and call the strategy only when a decision is due.

Scheduling and strategy memory solve different problems. The schedule decides when the strategy runs. State carries information the strategy itself needs from one decision to the next. This final version counts its decisions so the difference stays visible.

Return state_update beside the targets. At the next decision it arrives as ctx$state_prev; on the first decision, ctx$state_prev$decisions_seen is NULL. Returning a bare target keeps the previous state unchanged. State records decisions, not fills: a target returned at one pulse still fills at the next open.

weekly_trend_momentum <- function(ctx, params) {
  decisions_seen <- ctx$state_prev$decisions_seen
  if (is.null(decisions_seen)) decisions_seen <- 0L

  momentum <- ctx$vec$feature("return_5")
  trend <- ctx$vec$feature("sma_10")
  close <- ctx$vec$close

  if (!ledgr_passed_warmup(trend)) {
    targets <- ctx$hold()
  } else {
    rising <- momentum > 0 & close > trend
    weights <- ctx |>
      ledgr_selection(where = rising, missing = "exclude") |>
      ledgr_weight_equal()
    targets <- weights |>
      ledgr_target_rebalance(ctx, equity_fraction = params$invested)
  }

  list(
    targets = targets,
    state_update = list(decisions_seen = decisions_seen + 1L)
  )
}

weekly_params <- list(invested = 0.5)

You may also return one short intent_reason with the targets, such as "rebalanced". ledgr stores it as a decision note, not as a general store for predictions, ranks, selections or weights.

You might be tempted to call trend_momentum() from inside weekly_trend_momentum() instead of repeating the rule. Don’t: ledgr only runs self-contained strategies, and a call to your own function defined outside the strategy, such as trend_momentum(), is rejected before the run starts. vignette("reproducibility", package = "ledgr") explains why.

Calling the undecorated function still tests one decision in isolation. There is no previous state here, so decisions_seen starts at zero:

weekly_trend_momentum(pulse, weekly_params)
#> $targets
#> <ledgr_target> [4 assets]
#> non-NA: 4/4
#> DEMO_01 DEMO_02 DEMO_03 DEMO_04
#>       0     739       0       0
#>
#> $state_update
#> $state_update$decisions_seen
#> [1] 1

The decorator supplies timing when the strategy enters the experiment:

weekly_run <- ledgr_experiment(
  snapshot = snapshot,
  strategy = ledgr_strategy_schedule(
    weekly_trend_momentum,
    ledgr_schedule_week_start()
  ),
  features = features,
  opening = ledgr_opening(cash = 10000),
  cost_model = ledgr_cost_zero()
) |>
  ledgr_run(params = weekly_params, run_id = "v3_weekly")

ledgr_run_compare(snapshot)
#> # ledgr comparison
#> # A tibble: 3 x 11
#>   run_id     label final_equity total_return sharpe_ratio max_drawdown n_trades win_rate
#>   <chr>      <chr>        <dbl> <chr>               <dbl> <chr>           <int> <chr>
#> 1 v1_ranking <NA>        11372. +13.7%              2.63  -4.2%              76 69.7%
#> 2 v2_rule    <NA>        10874. +8.7%               1.88  -3.7%              93 63.4%
#> 3 v3_weekly  <NA>         9913. -0.9%              -0.173 -7.0%              26 69.2%
#> # i 3 more variables: valuation_state <chr>, assumed_terminal_outcomes <int>,
#> #   reproducibility_level <chr>
#>
#> # i 3 runs attempted: 3 done, 0 incomplete, 0 failed.
#> # i Only DONE runs are compared; incomplete and failed runs stay listed by ledgr_run_list().
#> # i Fill timing comparable: yes (same_timing_convention).
#> # i Full identity and telemetry columns remain available on this tibble.
#> # i Inspect one run with ledgr_run_info(snapshot, run_id).

decisions_seen now counts scheduled callback invocations, not market sessions. During warmup the invoked strategy explicitly returns ctx$hold(). On other non-decision sessions the callback is not invoked at all: ledgr records a scheduled skip, carries state forward, and still processes economic events, accounting and checkpoints. A scheduled skip and ctx$hold() therefore mean different things.

ledgr_run_compare() summarizes every run stored with the snapshot. n_trades counts sales that close earlier purchases, fully or in part; purchases alone are not counted, so it is smaller than the number of fills.

Each change did what it was designed to do: the rule stopped buying falling instruments, and the weekly schedule reduced how often it reconsidered the portfolio. That is not evidence that the weekly version is better. Three variants on four instruments over six months say nothing reliable about performance. vignette("selection-integrity", package = "ledgr") covers how to compare candidates without fooling yourself.

Try it

Decorate the same strategy with ledgr_schedule_month_start() under a new run_id, since a store keeps one run per ID. Compare the decision and skip counts with the weekly run. Checks like this are a cheap way to confirm that a new cadence changed only what you meant to change.

The fills show the trades caused by weekly decisions:

ledgr_results(weekly_run, what = "fills") |>
  select(ts_utc, instrument_id, side, qty) |>
  slice_head(n = 8)
#> # A tibble: 8 x 4
#>   ts_utc              instrument_id side    qty
#>   <dttm>              <chr>         <chr> <dbl>
#> 1 2019-01-29 10:00:00 DEMO_01       BUY      27
#> 2 2019-01-29 10:00:00 DEMO_04       BUY      24
#> 3 2019-02-05 10:00:00 DEMO_01       SELL      1
#> 4 2019-02-05 10:00:00 DEMO_03       BUY      31
#> 5 2019-02-05 10:00:00 DEMO_04       SELL     24
#> 6 2019-02-12 10:00:00 DEMO_01       SELL     26
#> 7 2019-02-12 10:00:00 DEMO_03       SELL     31
#> 8 2019-02-19 10:00:00 DEMO_01       BUY      12

Purchases happen only after scheduled decision pulses. Between them the strategy is not called, but ledgr continues the economic and accounting work of the run. The schedule is part of run identity and provenance, so changing the cadence creates a distinct experiment rather than an invisible callback rule.

Choose Another Calendar Cadence

The same decorator accepts other calendar declarations:

Desired decision Declaration Plan label
First open session of each week ledgr_schedule_week_start() week start
Every Wednesday ledgr_schedule_weekday("wednesday") wednesday
First open session of each month ledgr_schedule_month_start() month start
The 15th, or the next open session that month ledgr_schedule_month_day(15) month day 15
wednesday_schedule <- ledgr_schedule_weekday("wednesday")
month_day_15_schedule <- ledgr_schedule_month_day(15)

Calendar schedules move forward only inside the period they name. A closed Friday does not turn into a Monday decision. "saturday" and "sunday" decide only on venues that actually open then. A closed 15th may move to a later open session in that month, never into the next one. ledgr_schedule_month_day(1L) can decide on an open first session; ledgr_schedule_month_start() requires a provable boundary from the preceding month.

In v0.2.2, these declarations require an availability-aware axis with exactly one pulse per expected venue session. Dense runs and multi-pulse intraday runs refuse them rather than silently choosing which pulse represents the session.

The following executable check uses a calendar with Thursday and Monday open, but Friday and the weekend closed. Its complete decision table is empty:

#> # A tibble: 0 x 1
#> # i 1 variable: decision_date <date>

With A Changing Investment Universe

The examples above are already availability-aware because the snapshot declares its expected session calendar. Their four-instrument universe is constant, however. When membership changes over time, the set of instruments you may invest in changes too; vignette("survivorship-bias", package = "ledgr") shows one such run end to end. Four things then work differently:

  • ctx$universe can include an instrument you still hold but may no longer select, a held nonmember. ctx$members lists the instruments that are currently eligible.
  • ledgr_selection(), ledgr_signal(), and ledgr_select_top_n() choose only among members. Values you supply for held nonmembers are ignored, not ranked. ledgr_signal_return() and ledgr_signal_feature() also mark scores of target-inadmissible members as missing. A score you build yourself with ledgr_signal(ctx, values = ...) deliberately bypasses that convenience mask.
  • ledgr_target_rebalance() keeps each held nonmember’s quantity and reserves its value before sizing, so weights apply to what remains. With equity 100 and a held nonmember worth 40, weight 1 allocates 60, and weight 0.6 allocates 36. If your weights are fractions of total equity, for example from an optimizer, convert them first: 60% of total equity is weight 1 here.
  • ctx$state_prev also carries asset_state, one entry per instrument in ctx$universe for state that belongs to that instrument. An instrument that leaves the universe loses its entry; ?ledgr_strategy_context states the rules.

Neither membership nor ctx$tradable() guarantees that an order fills at the next open.

Cleanup

close(top_momentum_run)
close(trend_momentum_run)
close(weekly_run)
close(pulse)
close(first_pulse)
close(holding_pulse)
close(keep_pulse)
ledgr_snapshot_close(snapshot)

Where Next