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:
- rank the instruments and hold the top two;
- hold only the instruments that are actually rising;
- 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
The examples use four instruments from the offline demo data for the first half of 2019.
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 0On 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.5ledgr_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 FALSEWithout 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 0You 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 305The 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 4DEMO_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 30Nothing 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 0The 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] 177The 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 0trend_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] 1The 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 12Purchases 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$universecan include an instrument you still hold but may no longer select, a held nonmember.ctx$memberslists the instruments that are currently eligible. -
ledgr_selection(),ledgr_signal(), andledgr_select_top_n()choose only among members. Values you supply for held nonmembers are ignored, not ranked.ledgr_signal_return()andledgr_signal_feature()also mark scores of target-inadmissible members as missing. A score you build yourself withledgr_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_prevalso carriesasset_state, one entry per instrument inctx$universefor state that belongs to that instrument. An instrument that leaves the universe loses its entry;?ledgr_strategy_contextstates the rules.
Neither membership nor ctx$tradable() guarantees that an order fills at the next open.
Cleanup
Where Next
-
vignette("minimum-variance", package = "ledgr")builds a complete long-only minimum-variance strategy from causal history reads. -
vignette("ttr-indicators", package = "ledgr")declares TTR indicators such as RSI, Bollinger bands, and MACD. -
vignette("strategy-development", package = "ledgr")covers the strategy contract,ctx$flat()andctx$hold(), and a first run. -
vignette("sweeps", package = "ledgr")compares parameter values such asinvestedand parameterized schedules systematically. -
vignette("reproducibility", package = "ledgr")explains preflight tiers and why strategies must be self-contained.