Entry, exit, and timing
Entry and exit are each a program in the Signal language that returns a truthy Signal. The engine reads each signal's value at every spine minute: a truthy entry opens a position, a truthy exit closes what is open. Truthiness is level-based, there is no edge detection, and there is no dedicated time-of-day field. Timing is a comparison you write inside the signal. This page covers how the two signals fire, the footgun that comes with level-based firing, and how to gate on the clock.
Entry and exit are signals
A strategy carries one entry signal and one exit signal. Each is compiled from your DSL expression to a Signal, a {timestamp, value} series aligned to the per-minute backtest spine. At every minute the engine reads the entry value and the exit value for that minute and acts on each.
- A truthy entry opens a position, subject to the position limits. The position's legs come from contract selection.
- A truthy exit closes whatever position is open.
Both signals return a truthy Signal: a Float64 series whose value at each minute is 1.0 (act) or 0.0 (do nothing). Comparisons and logical operators already return this shape, so an entry expression is usually a comparison. See the execution model for how a comparison produces 1.0 / 0.0.
// Entry: open when 1-year IV rank is above 0.50
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
rolling_rank(iv_daily, 252) > 0.50The trailing comparison is the Signal the entry slot consumes.
Truthiness is level-based
Only a value of exactly 1.0 acts. The engine classifies each minute's value at the boundary and does nothing for anything else, recording a distinct reason:
1.00.0signal_false0.5, 2.0, -1.0)signal_falsenullsignal_nullNaNsignal_nanThere is no Python-style truthiness coercion. A value of 2.0 does not count as true, it is signal_false. Because comparisons and logical operators only ever emit 1.0 or 0.0, an entry or exit expression that ends on a comparison stays inside the acting-vs-not contract. A null (missing data) and a NaN (invalid computation) each read as no-action but keep their own reason, so you can tell a data gap apart from a broken expression when reading results.
Level-based means the signal fires on the level, not the transition. There is no edge detection.
A held-truthy entry re-attempts entry every eligible minute. If the entry value stays 1.0, the engine tries to open on every minute the position limits allow, not once per crossing. With the default max_concurrent_positions = 1, that means the strategy refills a slot the moment an exit frees it: exit at 15:50, and if entry is still 1.0 at 15:51 it opens again. The same applies to a held-truthy exit, which keeps closing whatever is open. Gate the level so it is true only when you want to act. The most common gate is the clock.
Writing a time-of-day rule
There is no Entry time field in the engine. Write timing as a comparison inside the signal, using time.minute_of_day(), time.hour(), or time.minute(). time.minute_of_day() returns minutes since midnight Eastern Time, so 09:35 is 9 * 60 + 35 = 575 and 15:50 is 15 * 60 + 50 = 950.
Enter once per session at 09:35:
// Entry: fire only at 09:35 each session (9*60 + 35 = 575)
time.minute_of_day() == 575Exit at 15:50:
// Exit: fire at 15:50 each session (15*60 + 50 = 950)
time.minute_of_day() == 950An equality on time.minute_of_day() is true for one minute per session, so it sidesteps the held-truthy footgun on its own: the level is 1.0 at exactly one minute and 0.0 everywhere else. Combine a time gate with a condition to enter once per day only when the condition also holds:
// Entry: at 09:35, only if 1-year IV rank is elevated
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
elevated = rolling_rank(iv_daily, 252) > 0.50
at_open = time.minute_of_day() == 575
elevated and at_openThe unconditional case
A strategy that "always enters" is a signal whose value is 1.0 at every minute. Because there is no edge detection, that signal attempts entry on every eligible minute, bounded only by the position limits. With max_concurrent_positions = 1 it opens one position, then re-attempts every minute and is turned away with position_limit until a slot frees, at which point it refills immediately.
That is rarely the intent. The practical form of "always enter" is a once-per-session time gate, which opens one position per day at a chosen minute:
// Enter once per session at 09:35
time.minute_of_day() == 575This is the same equality shown above. It is the unconditional entry most strategies want: one attempt per session rather than one attempt per minute.
What a signal can read in each context
Entry and exit signals are evaluated to a Signal and aligned to the spine before the run begins. They read the static market and calendar accessors, bar.*, option.*, and time.*, and saved signals via signal(...). No single held position is under evaluation while an entry or exit signal is read, so per-position and per-leg reads are not part of the entry or exit surface.
To gate on a held leg's greeks or on book-level state, use an adjustment trigger, which is the slot that evaluates against the open position. Contract selection is its own slot: it returns a contract via option.contract(...) and its parameters may reference another candidate leg's fields (a cross-leg dependency), but it does not admit the rest of the runtime surface. The full matrix of which accessors resolve in which slot lives in Signals inside a backtest.
// Entry gated on 1-year IV rank being elevated
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
rolling_rank(iv_daily, 252) > 0.50The 252 here is 252 observations, and it means one year only because iv_daily is sampled to daily first. On a raw 1-minute series, 252 would be 252 minutes. See the execution model for the observation-count rule.
Session edges bound when signals fire
Signals are only read on minutes that exist on the spine, and the spine is trimmed at both session edges. On a normal day the first spine minute is 09:35 and the last is 15:55; on a 13:00 half-day the last is 12:55. An entry at time.minute_of_day() == 575 fires at 09:35 because that minute is on the spine; an entry keyed to 09:31 never fires, because the spine starts later.
Within each minute the engine runs expiration force-close and exit before entry, with adjustments between them. A position that exits on a given minute frees its slot before entry is evaluated that same minute, which is why a held-truthy entry can refill within one minute.
The default session offsets trim 5 minutes off each edge: open_offset_minutes = 5 (first minute 09:35) and close_before_expiration_minutes = 5 (last minute 15:55 on a normal day, 12:55 on a 13:00 half-day). Both are configurable. These offsets bound every fill: no entry or exit can fill before the first spine minute or after the last. Change either offset and the eligible window, and the minute-of-day constants your rules compare against, move with it. The session calendar and half-days are covered in Data coverage, and the full per-minute order in The backtest lifecycle.
Building entry/exit: visual builder vs DSL
You author the entry and exit signals as programs in the Signal language, as shown above.
The Entry condition and Exit condition builders assemble a condition by selecting an operand, an operator, and a value, with grouped compound conditions, in the style of a database filter. Dedicated Entry time and Exit time fields set the session minute without writing a comparison. Leaving a condition blank means the strategy enters unconditionally on every qualifying session.
These builders are for entry and exit conditions only. Signals themselves are always written in the Signal language: the builder assembles the entry or exit gate, it does not author a signal.