DocsBacktestsAdjustments and triggers
BacktestsReference

Adjustments and triggers

Adjustments change a position while it is open: roll a leg, add a hedge, drop a leg, or resize. Each adjustment pairs one named trigger with one action, and the engine checks it every minute against every open position. This reference states each action, the accessors a trigger can read, and the typed no-action reasons; the trigger itself is written in the Signal language.

Updated Jul 20264 min read

What an adjustment is

An adjustment is a named trigger plus one action. The trigger is a Signal that returns a truthy value (Float64 1.0/0.0); the action is the change applied when the trigger reads 1.0. Each open position carries the strategy's full adjustment set. How the trigger is evaluated depends on what it reads. A trigger that references position() or leg(slug) is evaluated once per open position per minute against that position's own legs, so the same trigger can read 1.0 on one position and 0.0 on another held at the same minute. A trigger that reads only book() is evaluated once per minute for the whole account, and that single value is applied to every open position, so a book-level trigger that fires runs its action on all open positions at once. Either way, when the value reads 1.0 on a position, the trigger's one action runs on that position.

Trigger names must be unique within a strategy. The name is how a fired action, a rejection, and a no-action are attributed back to the trigger in the results.

// Trigger: roll when the short put's delta breaches 0.40
leg("short_put").delta > 0.40

The trigger reads live state on the position under evaluation. Here leg("short_put").delta is the current delta of the held short put at this minute, and the comparison returns 1.0 on the minutes where it sits above 0.40.

The four actions

Each adjustment carries exactly one action. Use the exact action name.

Action
What it does
Targets
replace_leg
Closes the named leg's current contract and opens a freshly resolved replacement, keeping the same slug. This is a roll.
One existing leg, standard multiplier (100 shares per contract) only
add_leg
Opens a new leg under a new slug on the open position. This is a hedge or a wing.
A new slug, standard multiplier (100 shares per contract) only
remove_leg
Closes and drops one leg. If it was the last leg, the position closes with reason adjustment_full_close.
One existing leg
scale_lots
Resizes the position by changing its lot count.
The whole position

replace_leg and add_leg resolve their contract at request time, the same way primary legs do (see selecting contracts). The replacement or added contract comes from an option.contract(...) selection evaluated at the adjustment minute, so the same threshold and tie-break rules apply.

Both replace_leg and add_leg operate only on standard-multiplier legs (100 shares per contract). A leg carrying a non-standard multiplier after a split raises multiplier_mismatch and the adjustment is a no-action.

Adjustment fills use the backtest's fill mode. A roll closes the old contract and opens the new one at that mode's price; a scale up or down fills the added or removed lots the same way. See fills, marks and slippage.

scale_lots parameters

Set exactly one of the three. Setting zero or more than one is rejected at configuration time.

Parameter
Type
Effect
delta_lots
Non-zero integer
Adds this many lots to the current count. Negative reduces.
target_lots
Integer, ≥ 0
Sets the position to this absolute lot count. target_lots=0 fully closes the position with reason adjustment_full_close.
delta_pct
Non-zero, ≥ -1.0
Scales the current lot count by this fraction. -1.0 is the floor; for a full close use target_lots=0.

A resolved lot count that lands below zero, or equal to the current count, is a no-action (adjustment_no_change). A scale up runs through the margin gate before it commits (see margin and auto-decomposition); if the account cannot support the larger position the action is rejected with margin_insufficient.

Triggers are level-based

Triggers are evaluated on level, not on edge. There is no latch and no debounce. A trigger that stays true fires every minute the condition holds, on every open position. So an action must be self-limiting or idempotent under repeated firing.

Warning

This is the same re-firing footgun as entry and exit signals. Write the action so firing removes the condition: a remove_leg that empties the position ends its eligibility, a replace_leg that rolls the leg out of the trigger region reads 0.0 on the next minute, and a scale_lots toward a fixed target_lots is a no-action once reached. An action that leaves the condition true keeps firing. See entry, exit, and timing for the same pattern on the entry side.

To control firing frequency, gate the trigger yourself. Combine the condition with a time-of-day or state check in the trigger Signal rather than relying on a built-in cooldown, because there is none.

A slug that is not present on the position resolves to null, which makes the trigger falsy, so no action runs and the engine records signal_null for that position and minute. A leg removed by an earlier adjustment stops driving any trigger that reads it.

What a trigger can read

An adjustment trigger runs in the adjustment context, which admits three accessors: book(), position(), and leg(slug). book() returns account-level state (net liquidation value, maintenance margin, excess liquidity, and the rest). position() returns state for the position under evaluation. leg(slug) returns state for the named held leg.

// Trigger: defend margin when account excess liquidity drops below $5,000
book().excess_liquidity < 5000

leg(slug) reads the live held leg on the position being evaluated, including its current greeks, quote prices, strike, dte, and multiplier at this minute. It also carries the entry snapshot captured when the leg opened, so a trigger can compare current state against the state at open. That snapshot is preserved across a roll: replace_leg keeps the slug, so a leg that has been rolled still reports the original open values. For the full accessor-context table and the columns each one exposes, see signals inside a backtest.

Slug rules

A slug names a role in the position, not a specific contract. That is what makes a roll possible: replace_leg swaps the contract underneath a slug while the slug stays put, so any Signal referencing that slug, including a rolling window on it, continues across the roll without resetting.

  • replace_leg and remove_leg must reference a slug that already exists on the position, either an original leg or one an earlier add_leg created.
  • An add_leg slug must not collide with a slug already on the position.

When an adjustment cannot run, it is a typed no-action: the position is left unchanged and the reason is recorded against the trigger name. The reasons:

Reason
Cause
leg_not_found_in_position
replace_leg or remove_leg referenced a slug not on the position.
duplicate_slug_in_position
An add_leg slug collided with an existing slug.
multiplier_mismatch
replace_leg targeted a non-standard-multiplier leg, or add_leg was attempted on a position whose existing legs carry a non-standard multiplier.
adjustment_no_change
The action resolved to no change (a roll to the same contract, or a scale_lots that lands on the current count or below zero).
contract_selection_failed
Contract selection returned nothing at the adjustment minute.
forward_quotes_empty
The resolved contract has no forward quotes to price the fill.
margin_insufficient
The post-adjustment position failed the margin gate.

Limitations

Two behaviors are not modeled.

  • Latch and debounce trigger semantics. Triggers are level-based only. There is no built-in state that suppresses re-firing after an action, and no built-in cooldown between fires. Gate the trigger yourself.
  • Computed leg slugs. A leg(slug) slug must be a literal. Building a slug from an expression is not available. Reference each leg by its written name.

See platform status for the full status ledger.

Was this page helpful?