Compare parameters and walk forward¶
Use run_grid to compare parameter combinations and run_walk_forward to
choose parameters on an earlier slice, then evaluate them on a later slice.
These helpers call a function you supply. They do not turn an on_tick
function, strategy declaration or strategy module into a backtest automatically.
They do not calculate trading returns, Sharpe ratios, costs or drawdown for you.
Choose a screening or execution model¶
Fast research modes answer narrower questions than the event-driven on_tick
engine. They can screen candidate signals, but switching engines does not
preserve execution prices, risk decisions, cash balances or returns.
Vectorized next-close simulation¶
run_vectorized(bars, on_signals, params=None) accepts a Polars DataFrame for
one instrument. Required columns are timestamp (Date, Datetime or integer
ordinal, strictly increasing, unique and non-null), symbol (one nonempty string, no nulls), and numeric
close. Prices and any supplied open, high, low or volume must be finite
and non-null. It does not sort or split mixed-symbol data for you. A typed empty
frame is valid and returns empty results.
Existing daily Date inputs and integer observation indices remain supported;
they are not converted to Datetime or interpreted as Unix time. Next-bar means
the next input row for all three types, regardless of calendar gaps. Returned
timestamps retain the input axis type. Floating-point and string timestamps
are rejected; convert real date/time strings explicitly before running.
The callback is called once for each observed prefix, from one row through
the full history. Return two Boolean Polars Series of exactly that prefix's
length: entries and exits. Only their final values decide that bar. Fill missing warmup signals explicitly with False. Entry and exit
cannot both be true on a row, including the final row. Enter only when flat and
exit only while long; repeated entries or exits from flat raise
NonVectorizableError, rather than silently resizing a position.
This complete example deliberately gives the next bar different open and close prices:
from datetime import datetime, timedelta
import polars as pl
from atom.engine.vectorized import run_vectorized
bars = pl.DataFrame({
"timestamp": [datetime(2026, 1, 1) + timedelta(days=i) for i in range(3)],
"symbol": ["A"] * 3,
"open": [100.0, 110.0, 120.0],
"close": [100.0, 150.0, 125.0],
})
def on_signals(bars, params):
return pl.Series([True, False, False]).head(bars.height), pl.Series([False, True, False]).head(bars.height)
result = run_vectorized(bars, on_signals)
assert result.orders["side"].to_list() == ["BUY", "SELL"]
assert result.orders["fill_price"].to_list() == [150.0, 125.0]
assert result.equity["position_value"].to_list() == [0.0, 150.0, 0.0]
The first signal buys one unit at the next row's close, 150; the second sells at the following close, 125. A final-row signal has no following fill. With the same signals and no costs, the actual event engine's default next-open convention instead fills at 110 and 120. The difference is intentional: existing vectorized next-close behavior is retained, not silently migrated to next-open.
The historical field name equity is retained for compatibility, but contains
position value only, not account equity or accumulated profit. There is no cash
ledger, capital admission, commission, protective-stop, short, multi-position,
settlement or recovery model here. slippage_bps defaults to zero and must be
finite, nonnegative and below 10,000; it increases buy prices and decreases sell
prices. A supplied fill_model.apply(order, bar) takes precedence over that
shortcut and must return finite prices. This model still does not acquire the
event engine's execution capabilities. Callbacks now receive only observations through the decision bar. A full-window
mean is therefore an expanding historical mean; a negative shift has no next
observation at the decision row and must handle its null explicitly. Future
suffix changes cannot alter earlier inputs. This costs one callback per bar,
so a calculation that scans every prefix can take quadratic work. Keep callbacks
pure and use only the supplied history/parameters: external future-data closures
or mutable global data are outside this guarantee. Previously callbacks saw the
whole frame once; fixed full-length masks must now be sliced to the supplied
prefix, and callbacks with side effects must be rewritten.
Start with at most 5,000 bars per research run and benchmark your own callback
before increasing that budget. This is an operational guard, not an enforced
limit: at 20,000 bars a full-prefix scan visits 200,010,000 rows versus 12,502,500
at 5,000 (16 times the work). Wide panels and expensive indicators multiply this
cost. Use event-driven incremental on_tick calculations for larger histories,
or reduce the research window explicitly; do not feed future rows to recover
speed. Both vectorized signals and triggered predicates retain prefix isolation.
Triggered intent screening¶
run_triggered(bars, strategy, params=None, state=None) evaluates
trigger_predicate(bars, params) on each observed prefix, then calls
on_trigger(bar, params, state) only on matching rows. The predicate returns a
Boolean Polars expression or a Python boolean. A scalar broadcasts; null,
non-Boolean or misaligned results are rejected. Omitting the predicate selects
every row. Original row indices appear as bar_index.
from types import SimpleNamespace
import polars as pl
from atom.engine.triggered import run_triggered
screen = SimpleNamespace(
on_start=lambda: ({"threshold": 120.0}, {"hits": 0}),
trigger_predicate=lambda bars, params: pl.col("close") > params["threshold"],
)
def collect(bar, params, state):
state["hits"] += 1
return [{"symbol": bar["symbol"], "side": "BUY", "qty": 1}]
screen.on_trigger = collect
result = run_triggered(pl.DataFrame({"symbol": ["A", "A"],
"close": [100.0, 150.0]}), screen)
assert result.triggers_fired == 1
assert result.final_state["hits"] == 1
assert result.metadata["result_kind"] == "order_intents"
The 150-price row produces one intent; it is not a fill or an admitted
position. No mark-to-market, risk supervision or lifecycle work occurs on
skipped rows. Callbacks share in-memory state during this screen, not durable
portfolio state. With multiple symbols, maintain any symbol-specific callback
state yourself. Returned orders retain their existing field name but are
explicitly labeled order_intents in result metadata.
Custom thresholds and periods remain accepted. on_start parameters override
supplied parameters. Known execution settings in either source are rejected,
even if set to None, false or zero: capital, risk limits, cash/margin,
commissions/fills, warmup, checkpoint/persistence, trade logs, schedules,
settlement (settlement_days and settlement_calendar) and lifecycle settings
belong to the event engine. Vectorized mode permits only its documented fill
model and slippage settings from this execution family. No market settlement
calendar is inferred.
Engine.start() routes hook-exclusive on_signals and on_trigger strategies
in backtest mode only and returns the native result in routed_result. It rejects
execution clients, feeds, persistence, resume, schedules and execution overrides
instead of ignoring them. Remove previously ignored execution settings only
when intent screening is what you want. Otherwise retain those settings and
use an on_tick strategy with the event engine. Recheck selected candidates
there before interpreting trading performance. Repository contributors can read
docs/benchmarks.md for measured costs and their limits; these are not trading
performance or production throughput guarantees.
Supply a runner¶
A runner has the contract runner(params, data_window) -> metrics: two
positional arguments and a flat dictionary with string keys and scalar
float/int/string/bool/null values. Keep the metric names and types consistent
between combinations. Metric names must not overwrite parameter names. Convert
NumPy scalar results to Python scalars before returning them.
For an actual trading experiment, your runner must construct a fresh backtest, apply the parameters, run the supplied data, check execution health and derive metrics from its results. Reset positions, cash, history, indicators and strategy state for each call. The helpers neither create nor reset that state. A cached hit skips the call entirely, so runners must not depend on side effects.
This complete example verifies the plumbing using arithmetic, not a strategy or a performance claim:
from atom.research import run_grid
def score_window(params, values):
return {"score": float(sum(values)) * params["scale"] - params["penalty"]}
def main():
results = run_grid(
score_window,
{"scale": [1, 2], "penalty": [0, 3]},
[1, 2, 3],
workers=1,
)
assert results["score"].to_list() == [6.0, 3.0, 12.0, 9.0]
print(results)
if __name__ == "__main__":
main()
The grid visits combinations in parameter insertion order, with the last
parameter varying fastest. Each result row contains the chosen parameters and
returned metrics. An empty parameter mapping {} runs once with no overrides;
an empty candidate list, such as {"scale": []}, raises ValueError.
Candidates must be non-empty lists or tuples. An empty data sequence is passed
to the runner as supplied; decide explicitly whether your runner accepts it.
workers=1 executes in the current process. workers=None, the default, uses
the machine's CPU count; a positive integer chooses the limit. Zero, negative
values, booleans and non-integers are errors. A single outstanding combination
runs in the parent even when the limit is larger. Put runners at the top level
of an importable Python module, and keep the if __name__ == "__main__" guard
when running a script with multiple workers. Lambdas and local functions fail
the picklability check, including with workers=1. Worker inputs also need to
be picklable. A source file is required for cached runners; interactive
definitions without retrievable source are not a supported cache identity.
Parallel grids serialize the runner/window once into an immutable pool input,
then send parameter dictionaries through a queue bounded to eight outstanding
tasks per requested worker. Mutable windows are deserialized fresh for every
experiment, preserving task isolation; plain immutable scalar/bytes windows can
be reused within a worker. The final result still needs one row per combination,
so total result memory grows with grid size. Smaller grids may spend more time
starting workers than calculating; choose workers=1 for cheap experiments.
Reuse identical experiments¶
Caching is off by default. Supply cache_backend=LocalDiskBackend(root=...)
to run_grid, or explicitly set ATOM_CACHE=1 to use ~/.atom/cache.
The backend's has, get and set calls all run in the parent process;
custom backends need not be picklable. Only cache misses are submitted for
execution. Cached and uncached results keep the same combination order.
The following complete example uses a temporary private cache, so it leaves no persistent research entries. Its walk-forward part uses the same arithmetic runner but does not enable caching:
import json
from tempfile import TemporaryDirectory
from atom.research import run_grid, run_walk_forward
from atom.research.cache import LocalDiskBackend
def score_window(params, values):
return {"score": float(sum(values)) * params["scale"]}
def main():
values = [1, 2, 3, 4, 5, 6, 7, 8]
with TemporaryDirectory() as directory:
backend = LocalDiskBackend(root=directory)
first = run_grid(score_window, {"scale": [1, 2]}, values,
workers=1, cache_backend=backend, cache_ttl_days=7)
again = run_grid(score_window, {"scale": [1, 2]}, values,
workers=1, cache_backend=backend, cache_ttl_days=7)
assert first.equals(again)
assert first["score"].to_list() == [36.0, 72.0]
result = run_walk_forward(
score_window, {"scale": [1, 2]}, values,
train_window=3, test_window=2, step=2,
workers=1, selection_metric="score",
)
assert result["train_start"].to_list() == [0, 2]
assert result["test_end"].to_list() == [5, 7]
assert [json.loads(p) for p in result["best_params"]] == [{"scale": 2}] * 2
assert result["oos_score"].to_list() == [18.0, 26.0]
print(result)
if __name__ == "__main__":
main()
Change workers=1 to workers=2 in a source-file script to use the process
pool when at least two combinations miss the cache. Cache hits do not start
workers. cache_bust=True forces recomputation and replaces stored results;
it does not disable caching. cache_ttl_days=None writes without expiry,
including clearing an older expiry on a replaced entry. Zero expires
immediately, including at the exact write timestamp. Finite negative TTLs retain
their existing meaning of already expired; fractional durations are supported.
NaN and positive/negative infinity are rejected. LocalDiskBackend.set validates
the duration before changing either file; run_cached validates it before
reading a hit or calling the runner. A custom grid backend owns its own TTL
validation. A hit does not refresh its expiry; set a new TTL with a forced
recomputation. Changing only TTL does not change an experiment's key.
What identifies the data?¶
Cache-key version 3 hashes complete content, not printed representations, and distinguishes supported timezone identities. Version 2 and older cache entries are ignored automatically; they are not deleted. Changing a hidden middle row now invalidates a result, while changing Polars display settings does not. Identity includes the following supported inputs:
- Polars DataFrames and Series: schema, column names/order, row order and values, including nulls and timestamp precision/timezone. Physical chunk boundaries are ignored. Object columns are unsupported, including nested ones.
- NumPy arrays/scalars: dtype, shape and every value in logical C order. Object arrays, structured dtypes and dtype metadata are unsupported.
- Atom
Bar, lists of bars andBarSeries: frame contents and declared metadata, including timestamp where present, symbols, mode, timeframe and adjustment flag. Treat bars/series as immutable after construction; replacing their frames behind already-materialized properties is not supported. - Nested plain lists, tuples and dictionaries of supported values, preserving their order, plus strings, bytes, bool/int/float/null, Decimal, enum values, dates/times, timedeltas, ranges, slices and pathlib paths. A path identifies its spelling, not the file contents. A string dataset identifier is safe only when you guarantee it names an immutable dataset revision.
Python datetimes and times may be naive, use the exact standard-library
datetime.timezone type (offset and name both participate in identity), or use
zoneinfo.ZoneInfo loaded by a named key, including ZoneInfo.no_cache(key).
ZoneInfo.from_file, dateutil/pytz timezones, subclasses and other custom
timezone implementations raise TypeError before a cached runner executes.
For example, 2026-01-01 12:00 in ZoneInfo("America/New_York") observes a
different offset 180 days later than a fixed UTC-05:00 time, so those inputs
must not share a result. Fixed-offset zones with different names also get
different keys because a runner can inspect tzname().
To migrate a dateutil regional datetime, explicitly convert it with
stamp.astimezone(ZoneInfo("America/New_York")), using the intended regional
key, before building the window. Do not strip timezone information or replace
it with a fixed current offset to silence the error: that loses future DST
behavior. Keyed ZoneInfo identity assumes a compatible timezone database;
include your timezone-data revision in explicit metadata when comparing
environments with different timezone rules.
Unsupported objects, lazy frames, sets, generators and cyclic containers raise
TypeError before the cached runner executes. Convert them to supported
materialized data or turn caching off by omitting the backend and unsetting
ATOM_CACHE. cache_bust=True still requires a valid identity. Uncached grids
can pass other objects through, subject to the worker serialization contract.
Keys also include parameters, runner source and Atom version. Function defaults and supported closure values are included, but imported helper implementations, dependency versions, external files, random seeds and mutable globals are not automatically tracked. Put every result-affecting revision/seed/configuration in explicit parameters or window metadata, and have your runner use it. Keep inputs unchanged during a call. Identical content is reusable within a compatible serialization environment; dependency upgrades can produce safe cache misses. Full-content hashing takes time and memory proportional to data size, so caching very cheap runners may cost more than recomputing them.
Cached metrics are JSON, never pickle. Invalid cache content raises an error;
use cache_bust=True to replace a damaged entry. The lower-level run_cached
returns stored bytes on a hit and stores only byte results on a miss; its
caller must encode/decode a safe data format. It does not serialize arbitrary
Python objects for you.
Use a private directory you control. Each cache entry stores its payload and
expiry together in one versioned record, committed through an exclusive random
temporary file and atomic replacement. Readers see one complete payload/expiry
pair, including with concurrent writers. A failed replacement leaves the previous
entry intact; both get and has enforce expiry. Opt-in JSON state checkpoints
retain their separate atomic writer. An existing predictable temporary symlink is
not followed. The cache rejects existing entry symlinks. Direct backend keys must
be nonempty ASCII filename tokens: start with a letter, digit, underscore or
hyphen, followed by those characters or dots. For example, run_1-v3.result is
valid; ../run, absolute paths and backslashes are rejected for reads and
writes. Normal run_grid/run_cached keys already use safe generated hashes.
Nonfinite expiry metadata is treated as a cache miss. These checks do not
protect a directory that an adversary can modify between filesystem operations,
authenticate cached results or make a writable shared directory
trusted. Atomic replacement is not a complete power-loss durability guarantee.
For example, an entry written at time 100 with a one-second TTL becomes a miss
at time 101 even if an earlier has call succeeded. Replacing it with a no-expiry
entry commits that payload and its no-expiry state together. A failed replacement
cannot remove the old expiration or publish only the new bytes. Local files now
use .entry; old .bin/.ttl pairs are ignored and recomputed on demand because
an interrupted old write cannot be migrated safely. They are not automatically
deleted. The public backend still accepts and returns opaque payload bytes.
Choose walk-forward windows¶
Window sizes and step count observations, not days. Pass a chronological,
sliceable sequence, usually a list of bars. With train 3, test 2 and step 2,
the example trains on indices [0, 3), tests on [3, 5), then trains on
[2, 5) and tests on [5, 7). The trailing eighth observation cannot fit a
third complete pair and is omitted. Each training slice precedes its own test
slice. Different folds can overlap; step < test_window overlaps test slices,
so summing their results would double-count observations.
Train/test/step must all be positive integers; booleans and fractional values are rejected before window generation. Empty or insufficient data produces an empty DataFrame with no columns. Invalid worker counts and empty candidate lists still raise even when no complete window fits. The helper does not sort data, validate timestamps or detect information leakage inside your runner.
selection_metric defaults to "sharpe", but your runner must actually supply
that metric. Higher is better; negate a loss if you want to minimize it. Every
training score must be a finite number. Missing metrics now raise instead of
silently selecting the first combination. Ties choose the first combination.
The chosen original parameter values are passed to the test call, which runs
in the parent process. No training state or positions are carried into it.
Include any warmup/history handling deliberately in your runner without using
future observations to select parameters.
Rows contain exclusive-end index boundaries, best_params as JSON text and
test metrics prefixed oos_. Use JSON-compatible parameter values when you
need to reconstruct them exactly; non-JSON values in best_params use string
representations. There is no automatic aggregation or trading-return claim.
run_walk_forward has no backend/TTL arguments; ATOM_CACHE=1 enables only its
training grids' default cache, not test calls. For isolated experiments leave
that environment flag unset.