Strategy Lifecycle
A QuantCraft strategy is a regular Python file that defines a small set of callback functions. The backtest engine calls those functions in a fixed order while it walks through historical data. You don't call them yourself — the engine does. Use Test to drive them (running code).
The lifecycle, from start to finish:
on_init → on_bar → on_tick (×4 per bar) → account tick(close) → on_finish
└─────────── repeats for every bar ───────────┘In backtest, on_tick fires four times per bar on synthetic OHLC from each bar (no real tick history). After those four ticks the engine does a final account mark-to-market at close — not a fifth on_tick. on_timer is only available on forward runs (chart forward, bulk forward / QuantCloud) when timerIntervalMs is set — the IDE Run backtest modal has no timer control. On forward runs, on_tick runs on every broker price update from your connected broker; intraday boundaries often fire on_tick then on_bar — do not assume backtest order.
This page covers each callback, how multi-symbol strategies plug into them, and the basics you need to write your first strategy.
The strategy file
A minimal strategy looks like this:
import quantcraft.process.runtime as bt # account, chart_indicators, qc_* handles
from quantcraft import Timeframe
import quantcraft.runtime as rt
def on_init(symbols=None):
pass
def on_bar(bar_index, bar, fundamentals=None, calendar=None, symbol=None):
close_px = float(bar["close"])
daily = rt.get_bar(Timeframe.DAILY, shift=1)
def on_tick(bar_index, tick_in_bar, price, bar, fundamentals=None, calendar=None, symbol=None):
if tick_in_bar == 3:
close_px = price
def on_finish(bars, symbol=None, bars_by_symbol=None):
passA few rules apply across all callbacks:
- They must be top-level functions in your file (not nested or inside a class).
- The backtest engine requires callable
on_init,on_bar,on_tick, andon_finish(otherwise the run fails withUser script must define callable: …). The IDE Test, Compile and Debug Test buttons appear when the active.pytab defineson_init/on_bar/on_finish; a missingon_tickstill fails when the backtest starts.on_timeris optional and only used on forward runs with a timer interval. - Add only the optional arguments you use.
calendar,symbols(onon_init) and theon_finisharguments are matched by name;fundamentalsandsymbolare matched by trying the common shapes in turn, so keep them in the documented order. The safest full signature isdef on_bar(bar_index, bar, fundamentals=None, calendar=None, symbol=None). A declaredcalendaralways receives the calendar handle orNone— never the fundamentals object. - Inside callbacks you place orders through
accountand record indicator values throughchart_indicators(indicator series) — both read fromquantcraft.process.runtime.
Runtime globals
The engine exposes these from quantcraft.process.runtime before callbacks run:
| Name | Purpose |
|---|---|
account | PaperAccount instance configured from the Simulation tab (balance + commission / slippage). On forward runs it is a live broker account with the same API that places real orders — see Account model |
chart_indicators | ChartIndicatorSeries collector merged into the result — collect values as the run walks bars, emit the series in on_finish |
qc_fundamentals | Optional QcFundamentals handle when fundamentals are loaded |
qc_fama_french | Optional QcFamaFrench when global Fama-French factors are loaded; prefer fama_french.qc in callbacks — see Fama-French factors |
qc_calendar | BacktestCalendar | None — calendar data when Include calendar data is enabled; covers every traded symbol. Backtests only — always None on forward runs. Callbacks can receive the same handle by naming a calendar argument instead — see Calendar data |
qc_ohlcv_by_tf | Optional secondary timeframe buffers keyed by timeframe string (advanced; prefer get_bar) |
get_bar(tf, shift=1) | Recommended API for the primary and additional timeframes — returns a BarSnapshot or None; import via import quantcraft.runtime as rt |
Prefer the bars argument of on_finish(bars, ...) for full-series math — do not rely on module-level qc_ohlcv in IDE strategies (it is often None at import time, is None on a screener-target run, and elsewhere spans the full loaded range including warmup). Read prices from the bar object in callbacks. isinstance(bar, dict) is False — OhlcvBarView is a Mapping (dict-like access still works).
Read these as module attributes. Each handle is backed by a per-session value, so
bt.account/bt.chart_indicatorsalways refer to this run.from quantcraft.process.runtime import accountbinds a snapshot — fine in the root algo file in a backtest, because the engine rebinds those names after loading it (forward runs rebind onlyaccount,qc_fundamentalsandqc_fama_french, so usebt.chart_indicatorsandrt.qc_ohlcv*there), but wrong in a split helper module, which stays cached insys.modulesand would keep the first session's handle. See Module handles vs imported names.
Secondary timeframes
The primary run timeframe is always available via rt.get_bar. Select extra bar sizes in the Run backtest modal Additional timeframes dropdown on Symbol & data (Backtests), then read them from any callback:
from quantcraft import Timeframe
import quantcraft.runtime as rt
def on_bar(bar_index, bar, fundamentals=None, calendar=None, symbol=None):
daily = rt.get_bar(Timeframe.DAILY, shift=1)
if daily and bar.close[0] > daily.close:
account.open_trade(...)Full shift semantics, Timeframe enum, and forward-run caveats are in OHLCV and bar data.
Fill timing
bar["close"]inon_baris the current execution bar's close.account.open_trade/close_tradetake a requested price, then apply the run’s slippage and commission models. The storedentry_price/exit_priceare the fill prices; requested prices are kept asraw_entry_price/raw_exit_price. With the default volume-share slippage, fills usually differ from the price you pass.- To target the bar close from
on_bar, passfloat(bar["close"])(still subject to slippage/commission). - To fill during intrabar simulation, call from
on_tickwhentick_in_bar == 3—priceis that symbol's close for the row.
on_init — set things up
Runs right after the engine has prepared the account, loaded market data, and built the runtime — but before any bars are processed.
Use it to:
- Initialize variables your strategy carries between bars (counters, state machines, parameter values).
- Configure indicators or warmup buffers.
- Read input parameters so they can drive the rest of the run.
def on_init(symbols=None):
global lookback, position_size
lookback = 20
position_size = 100You don't have access to a current bar in on_init — at this point the simulation hasn't started yet.
symbols parameter (screener runs only): When the backtest uses a screener symbol source, on_init re-fires at every rebalance with symbols=<list of tickers> for the new universe. This lets your strategy react to a fresh ticker set without restarting the simulation.
- At run start,
on_initfires twice in quick succession — once withsymbols=None(the standard setup call) and once withsymbols=<initial tickers>. - Re-fires do not reset module-level state; any variables you set in the first call are still there.
- Strategies that define
def on_init():(no parameter) work exactly as before — the engine matches arguments by name, so the missing parameter is simply ignored.
on_bar — once per bar
Runs once per execution bar, before the four intrabar ticks for that bar. This is where most strategies make decisions.
def on_bar(bar_index, bar, fundamentals=None, calendar=None, symbol=None):
if bar.bars_back < 1:
return
if bar.close[0] > bar.close[1]:
account.open_trade(...)Parameters:
bar_index— zero-based index of the current execution bar.bar— the current bar view (OhlcvBarView). Behaves like a read-only mapping (bar["open"],bar["high"],bar["low"],bar["close"],bar["volume"],bar["t"]) and also exposes history through offsets:bar.close[0]is the current bar's close,bar.close[1]is the previous bar, and so on.len(bar.close)andbar.bars_backtell you how much history is available. It is not a plaindict(isinstance(bar, dict)isFalse).fundamentals— fundamentals snapshot for this bar, orNoneif not configured.calendar—BacktestCalendarhandle when Include calendar data is enabled, otherwiseNone(alwaysNoneon forward runs). See Calendar data for the full API. Arguments are matched by name, so omitting this parameter leaves your callback unchanged.symbol— uppercase ticker string. Useful in multi-symbol strategies; you can omit it if you only load one symbol.
Common patterns:
- Decide on entries/exits at bar close: pass
bar.close[0](orfloat(bar["close"])) toaccount.open_trade/account.close_trade. - Compute simple indicators by reading offsets like
bar.close[0],bar.close[1], … - Skip early bars that don't have enough history (
if bar.bars_back < N: return).
Tip:
on_baralways runs before any tick callbacks for the same bar.
on_tick — intrabar and live price updates
on_tick uses the same signature in backtest and forward runs, but when it fires and what price means depend on the run mode.
Backtest (Test)
Runs up to four times per bar, after on_bar, to simulate movement inside the bar. The engine walks the OHLC of each bar in this order:
tick_in_bar | Meaning |
|---|---|
0 | Bar open |
1 | Bar high |
2 | Bar low |
3 | Bar close |
No real tick data in backtest. Historical runs do not load broker tick history. The four calls per bar use synthetic prices derived from each bar's OHLC only. You can approximate intrabar fills in simulation, but backtest
on_tickbehavior will not match live forward ticks. For bar-close decisions,on_baris enough —on_tickwithtick_in_bar == 3is equivalent to acting on the bar's close in simulation.
def on_tick(bar_index, tick_in_bar, price, bar, fundamentals=None, calendar=None, symbol=None):
if tick_in_bar == 3:
# Backtest simulation: closing tick fill
account.close_trade(position_id, price)Use backtest on_tick when timing within the bar matters in simulation — for example to fill on the closing tick, simulate an intrabar stop, or evaluate a touch of the bar's high or low.
Forward runs (chart forward, bulk forward / QuantCloud)
On forward runs, on_tick runs every time the platform receives a price from your connected broker while your algo is attached. There is no fixed four-tick OHLC walk — each call reflects a live quote.
def on_tick(bar_index, tick_in_bar, price, bar, fundamentals=None, calendar=None, symbol=None):
# Forward run: react to each broker price update
if price <= stop_level:
account.close_trade(position_id, price)price— the latest broker quote for this symbol.bar— the current bar view for history (same shape as in backtest).tick_in_bar— may still be present for signature compatibility; on forward runs, branch onprice, not on synthetic OHLC phases.
Use forward on_tick for live intrabar logic — stops, trailing exits, or any reaction to marks between bar closes.
Parameters (both modes)
bar_index— same as inon_bar.tick_in_bar— in backtest,0,1,2, or3(synthetic OHLC step). On forward runs, do not rely on it for bar-phase logic.price— synthetic OHLC step in backtest; live broker quote on forward runs.bar— the same bar view as inon_bar.fundamentals,calendar,symbol— same meaning as inon_bar.
If you only need bar-close decisions in backtest, you can ignore
on_tickentirely and put your logic inon_bar.
on_timer — optional periodic callback (forward runs only)
IDE backtest: The Run backtest modal does not set a timer interval. Even if you define
on_timerin your strategy file, it is not called during IDE backtests. Put periodic or time-based logic inon_baroron_tickfor backtests.
Forward runs (chart forward test, bulk forward / QuantCloud) can set timerIntervalMs in their run modal. When configured, on_timer runs at a fixed time interval between ticks, only when two conditions are met:
- You have configured
timer_interval_ms/timerIntervalMsfor the run (a positive integer in milliseconds). - Your file defines an
on_timerfunction.
def on_timer(bar_index, tick_in_bar, price, bar, fundamentals=None, calendar=None, symbol=None):
passThe signature is the same shape as on_tick. Use on_timer for time-based behaviors (for example: refresh a slow-moving signal every minute, or schedule a check independent of bar count). If timer_interval_ms is not set, on_timer is never called even if it's defined.
Calendar data in callbacks
When Include calendar data is enabled on the Symbol & data tab, any callback can receive a BacktestCalendar handle by adding a calendar parameter:
def on_bar(bar_index, bar, calendar=None, symbol=None):
if calendar is None: # always guard — not every run enables calendar data
return
days = calendar.days_to_earnings(bar["t"], symbol)
if days is not None and days <= 2:
return # avoid trading into an earnings print
divs = calendar.dividends(bar["t"], symbol)
if divs and (divs[-1].amount or 0) > 0.5: # amount can be None
...The same handle is available on on_tick and on_timer — but calendar data is backtest-only, so on forward runs calendar is always None. All lookups are point-in-time — dividends(bar["t"], symbol) returns only dividends that were public at that bar, never future ones.
Key methods:
| Method | Returns |
|---|---|
dividends(bar_time, symbol=None) | Dividends public at that bar (oldest-first) |
splits(bar_time, symbol=None) | Splits announced by that bar (oldest-first) |
next_earnings(bar_time, symbol=None) | {date, basis, estimated, …} or None |
days_to_earnings(bar_time, symbol=None) | int or None |
has(symbol=None) | Whether this symbol has calendar data at all |
Lists are oldest-first — [-1] is the most recent event. This is the opposite of fundamentals, where index 0 is the newest period.
The qc_calendar runtime global holds the same handle when you prefer to read it from quantcraft.process.runtime rather than the callback argument.
See Calendar data for event field schemas, corporate-action simulation details, and troubleshooting.
on_finish — wrap up
Runs once, after the last bar. Use it to compute final outputs, build chart indicators, or print a summary.
def on_finish(bars, symbol=None, bars_by_symbol=None):
closes = [b["close"] for b in bars]
bt.chart_indicators.add(...)Parameters:
bars— chronological list of OHLC dictionaries for the clock symbol, oldest first — the executed window, the same rows the Test Results chart draws. Each entry hast,open,high,low,close,volume, and optionallyfundamentals. This is the canonical full-history series foron_finish.symbol— uppercase ticker of the clock symbol.- On a Screener run there is no clock symbol:
barsis[]andsymbolis"", so usebars_by_symbol. bars_by_symbol— for multi-symbol runs, a dict mapping each ticker to its chronological list of bars (aligned with the execution clock). For single-symbol runs you can ignore this.
Common uses:
- Hand indicator series to
chart_indicators.add(...)so they appear on the result charts. Prefer values you accumulated per symbol as the run walked bars; recompute frombarsonly for indicators you did not track per bar — see Indicator series. - Print or log a final summary to the Output Panel.
- Refresh portfolio metrics for the result payload.
Every
returninon_finishis silent. The run still succeeds and Test Results still renders — an early return just means your series or metrics never got attached, with no error anywhere. Print tosys.stderrat each guard while debugging; the message shows up in the Output panel.
Multi-symbol basics
QuantCraft strategies can run against one symbol or several at once. The lifecycle stays the same — only the callback signatures and the data they receive change slightly.
Configuring multiple symbols
On the Symbol & data tab in Run backtest, select one or more uppercase tickers. All listed symbols are loaded and simulated together.
The clock symbol
When you run multiple symbols, one of them drives the clock — its bar timestamps decide when callbacks run. The Run backtest modal has no primary-symbol control. Resolution order:
- A top-level
SYMBOL = "AAPL"in your strategy file, if that ticker is in the loaded list. - Otherwise the first loaded symbol.
With a Screener symbol source there is no clock symbol at all — the symbol set changes as the run goes. rt.get_bar always reads the clock symbol's bars (and returns None on a Screener run), so for another ticker's history use that ticker's own bar.close[i] offsets.
The clock symbol is also what on_finish(bars, symbol=...) uses for its bars and symbol arguments.
Per-symbol callbacks
For each execution bar, the engine walks symbols in list order:
on_barruns once per loaded ticker at this bar time (before any ticks for that bar).- Backtest only: for each
tick_in_bar(0= open,1= high,2= low,3= close),on_tickruns once per ticker on synthetic OHLC (no real tick data). - Backtest only: after the four tick rounds, a final account
tick(close_map)marks equity at close — not a fifthon_tick. - Forward runs:
on_tickruns per broker price update per symbol as quotes arrive from your connected broker (not limited to four calls per bar). Intraday boundaries often fireon_tickthenon_bar— do not assume backtest order. on_timer(only on forward runs withtimerIntervalMsconfigured — not in IDE backtest) follows the same per‑ticker pattern between ticks.
That means a multi-symbol on_bar is invoked, for example, three times per bar if you have three symbols loaded. Use the symbol parameter to know which ticker the call is for:
def on_bar(bar_index, bar, fundamentals=None, calendar=None, symbol=None):
if symbol == "AAPL":
...
elif symbol == "MSFT":
...The bar view always belongs to the symbol the callback was invoked for. The same applies to price in on_tick.
As-of alignment. Per execution bar time, each ticker gets its latest bar at or before that time. A ticker with no bar at that exact time (a halt, a missing minute, a different trading calendar) receives its previous bar again. If you count bars or signals per ticker, skip repeats by remembering the last bar["t"] you saw for that symbol:
_last_t = {}
def on_bar(bar_index, bar, fundamentals=None, calendar=None, symbol=None):
if _last_t.get(symbol) == bar["t"]:
return # same bar as last step for this ticker
_last_t[symbol] = bar["t"]
...on_finish for multi-symbol
on_finish still runs once at the end. To work with all symbols' history, use bars_by_symbol:
def on_finish(bars, symbol=None, bars_by_symbol=None):
if bars_by_symbol:
for ticker, ticker_bars in bars_by_symbol.items():
...Putting it together
A typical single-symbol strategy:
- Sets input parameters or other state in
on_init. - Reads price history (OHLCV) through
bar.close[0],bar.close[1], … inon_barto make trading decisions, and callsaccount.open_trade/account.close_trade. - Optionally uses
on_tick— in backtest, for synthetic intrabar fills (e.g.tick_in_bar == 3for the closing tick); on forward runs, to react to live broker prices between bar closes. - Optionally uses
on_timeron forward runs whentimerIntervalMsis configured (not available in IDE Run backtest). - Builds chart indicators and finalizes results in
on_finishusingbars.
A multi-symbol strategy uses the same callbacks, just branches on the symbol argument inside them and reads per-ticker data through the bar it receives — and through bars_by_symbol in on_finish.
Need more detail — see Account model, Fundamentals, Fama-French factors, OHLCV and bar data, Indicator series, or the in-app Documentation tab in the right sidebar (workspace layout).
