Filters & Signals
Mark the backtesting timeline: signals for moments, filters for on/off ranges — the class shape, the ctx surface, and the rules.
A Filters & Signals script marks the backtesting timeline. It declares named outputs of two kinds — and one script may host both:
- Signal — a moment in time: "this just happened" (a breakout, a session open). The output is the list of timestamps where it fired, rendered as markers on a timeline track.
- Filter — a boolean condition over time: "is this true right now?" (in-session, above the moving average). The output is the time ranges where it was true, rendered as shaded ranges on a timeline track.
Use them to jump straight to the interesting places while backtesting manually, and as building blocks a Strategy script references.
Shape and lifecycle
A script is a TypeScript class, default-exported. No base class, no imports of external packages. State lives in instance fields.
export default class implements Script {
private ema = 0; private k = 0;
onInit(ctx: InitContext) { // once, before any market data
this.k = 2 / (ctx.inputs.emaLen + 1);
}
onUpdate(ctx: Context) { // every market update, in time order
this.ema += this.k * (ctx.bid - this.ema);
ctx.filters.aboveEma.set(ctx.bid > this.ema);
if (ctx.isNewBar && ctx.bid > this.ema) ctx.signals.crossUp.raise();
}
onFinish?(ctx: Context) { /* optional — rarely needed */ }
} onInit(ctx)— runs once before the data feed. Getsinputs,timers, anddeps— no market data yet. Subscribe timers and configure dependencies here.onUpdate(ctx)— called for every market update, in time order; must not beasync. It sees bid and ask, finer than bars. Optional if a timer is subscribed inonInit— one of the two is required.onFinish(ctx)— optional, once after the last update.
The ctx you get each update
| Member | Meaning |
|---|---|
ctx.time | Current update time — milliseconds since the Unix epoch |
ctx.bid / ctx.ask | Current bid / ask price |
ctx.isNewBar | true only on the first update of a new bar |
ctx.bar | The current forming bar — OHLCV so far, plus its open time |
ctx.bars | Bar history, read via methods — e.g. ctx.bars.close(1): index 0 = forming bar (same as ctx.bar), 1 = previous closed bar, … count includes the forming bar; depth = at least metadata.historyBars (see below) |
ctx.inputs | Resolved input values, typed from metadata |
ctx.symbol | Instrument facts: ticker, dataProvider, pipSize, priceDigits, hasTrades (priceDigits is NaN during onInit, real from the first update) |
ctx.run | This run's start / end (ms) and interval (minutes) |
ctx.signals.<name>.raise() | Emit a signal event at ctx.time |
ctx.filters.<name>.set(bool) / .prevValue | Set / read the filter state |
ctx.timers | Subscribe clock-driven callbacks — see Timers below |
ctx.deps.<alias> | Referenced scripts and indicators (only if declared in metadata) |
ctx.roles | Which outputs this run collects (advanced; raising a signal or setting a filter the run doesn't collect is a safe no-op) |
ctx.time is in milliseconds. Custom indicators use Unix seconds in bar.timestamp — server scripts use millisecond
epochs everywhere (ctx.time, ctx.run, timers). Don't carry
the seconds habit over.
The forming-bar trap
onUpdate runs many times within the same bar while it
forms. Code that accumulates per call (this.sum += ctx.bar.close)
double-counts. Gate per-bar logic on ctx.isNewBar, or recompute from ctx.bars — closed bars start at index 1.
Signals
ctx.signals.<name>.raise() — no arguments; the event timestamp is ctx.time. Raise as often as the logic demands; each raise is one marker on
the track.
Filters
ctx.filters.<name>.set(active):
- Call cadence is free — set every update or only on change; not calling carries the previous value forward. The engine turns consecutive values into clean time ranges.
prevValue: boolean | null— the carried value;nullonly before the first set.
Every declared filter must be set during the first update. There is no default (the starting value depends on the market) — the run fails with an error naming any filter still unset after the first update.
Timers — logic across data gaps
Market data has gaps — forex weekends mean no updates from Friday close to Sunday open, so a tick-driven filter cannot close a session at the Friday close. Timers fix this:
onInit(ctx: InitContext) {
ctx.timers.every(60 * 60 * 1000, (c) => { // hourly, epoch-aligned marks
c.filters.inSession.set(isSessionHour(c.time));
});
} ctx.timers.every(intervalMs, cb)— the callback fires at every aligned mark, even inside data gaps (delivered in correct time order), plus once at the range start.c.timeis the mark; price and bar fields reflect the last real update. Returns a handle with.stop().ctx.timers.at(timestamp, cb)— one-shot: fires once attimestamp(ms). A past timestamp fires at the next opportunity. Same handle with.stop().
Use timers for session and calendar boundaries; use onUpdate for price
logic.
metadata.json
The metadata that matches the class above:
{
"name": "EMA Trend", "shortName": "EMAT",
"historyBars": 50,
"inputs": {
"emaLen": { "type": "int", "default": 20, "min": 1, "max": 500, "name": "EMA Length" }
},
"signals": {
"crossUp": { "name": "Cross Up", "color": "#26a69a", "description": "Bid crossed above EMA" }
},
"filters": {
"aboveEma": { "name": "Above EMA", "color": "#787b86" }
}
} - At least one output (
signalsand/orfilters) is required — see how the kind is derived. - Input types:
int/float(optional min/max/step) /bool/enum(the default must be one of theoptions). Keys must be identifiers — they becomectx.inputs.<key>. inputGroups— presentation-only sections for the parameters form; agroupkey on an input refers to a group. Never affects behavior.historyBars— closed bars preloaded before the range start (so lookbacks are warm at the first update) and the depth ofctx.bars. This sets your request; the run loads the deepest requirement across the script and its dependencies, soctx.barsmay reach further back. Default 0.
Referencing indicators and other scripts
A Filters & Signals script may declare dependencies in metadata
(alias → pinned script) and consume them via ctx.deps.<alias> — most
usefully indicators: read ctx.deps.myRsi.plots.rsi each update, recomputed exactly as on the chart.
Configure with .setParams({...}) in onInit. Dependency
versions freeze at save — pinning and Update to vN are covered in Scripts explained.
A dependency can never be a strategy. The full consumer API lives on the Strategy scripts page — it's the same mechanism.
Saving and diagnostics
Save type-checks the code against the metadata-generated API and returns precise line/column diagnostics — see Scripts explained for drafts, versions, and the full save lifecycle.
Limits and where results go
- Each output records at most 10,000 items per run (signal events or filter flips). Hitting the cap stops recording for that output and flags it as truncated — the run still succeeds. A signal firing 10,000+ times usually means the condition is too loose to be a useful marker.
- Runs count against your daily compute time. Compiles on save never count.
- Results are deterministic — same version, parameters, and range, same result.
To see the output, open the timeline's Filters & Signals window (the + button) and pick your script — its markers and shaded ranges render over the backtesting timeline, are remembered in this browser (see Timeline), and recompute when the range or instrument changes.
Something missing or wrong? Email support@strategytune.com.