Language reference

ScanLang

v1.0.0

ScanLang is the safe, declarative screening language behind the AI strategy builder. A program reads a symbol’s price and volume history and returns one verdict — a pass/fail, a score, or a named state. A purpose-built screening language, designed so that everything you write is safe by construction.

No imports, no file or network access, no arbitrary code — the interpreter is the sandbox. This reference covers the language end to end, from its five value types to multi-timeframe breakouts.

A complete indicator
timeframe 5m
requires 50 bars
params {
  period = 50 : int(2..400)
}

let fast = ema(close, 8)
let slow = ema(close, period)

guard close > 0
output fast > slow

Headers, then a body, ending in exactly one output.

01ScanLang

The big picture

ScanLang is the safe, declarative screening DSL used to author custom indicators for the scanner. A single program computes one per-symbol decision or scorefrom a symbol’s price and volume history — nothing more, nothing less.

The language is deliberately small. There is no exec, no imports, no attribute access, and no file or network I/O. The interpreter isthe sandbox: programs are safe by construction, which is precisely what lets users — and the AI indicator-creation tool — author and save indicators with no code-jailing, no containers, and no review gate.

A compiled ScanLang program exposes the same calculate(bars, params[, context])contract as a hand-written Python indicator, and it delegates every bit of its math to the same vetted helper functions. As a result a ScanLang indicator scores byte-for-byte identical to its Python equivalent — we call this parity by construction. You are not approximating the Python engine; you are driving it.

A program reads strictly top-to-bottom: headers first (timeframe, requires, params), then a body of statements ending in exactly one output. The output is the indicator’s verdict for the symbol — a boolean, a number, or a categorical state.

A representative program
timeframe 5m
requires 50 bars
params {
  period = 50 : int(2..400)
}
let fast = ema(close, 8)
let slow = ema(close, period)
guard close > 0
output fast > slow

Everything else in the language — windows, sessions, loops, details, visuals — layers on top of this core shape without changing it.

02ScanLang

Program structure

Every program has the same skeleton: an optional run of headers describing what data the indicator needs, followed by the statements that compute its answer.

Grammar
program  := comment* header* statement*
header   := timeframe | requires | params   // each at most once, before any statement

The rules are few and worth internalizing:

  • Comments may appear anywhere— before headers, between statements, or at the end of a line.
  • Headers are optional, but when present each may appear at most once and all of them must come before any statement.
  • The body is a sequence of statements, and exactly one of them must be an output— the program’s single result.
  • Whitespace is insignificant, with one exception: a newline terminates a statement.
Note
Headers are not just bookkeeping. timeframe selects which bar series the program runs against, and requires N bars tells the scanner how much history to fetch and lets it skip symbols that have too little data to score reliably.
03ScanLang

The value model

Understanding how values behave is the key to reading and writing ScanLang fluently. At runtime every value is one of just five types.

TypePython representationExamples
SERIESa list aligned to the barsclose, ema(close, 8)
NUMBERint / float42, 1.5, bar_count
BOOLbooltrue, close > open
STATEstr"above_both"
BARSlist of bar dictssession.bars, drop(bars, 1)

The reduction rule

This is the single most important concept in the language. A SERIES is a whole list of values, one per bar, but most of the time you only care about where the symbol is right now. ScanLang resolves this automatically.

The reduction rule
In any scalar context— arithmetic, a comparison, a NUMBER argument, or the value of output, guard, or detail — a SERIES automatically reduces to its latest finite value. So output close > ema(close, 50) compares the latest close against the latest EMA value, not element-by-element.

Functions that genuinely need the whole list — such as ema and sma— declare a SERIES parameter and therefore receive the series untouched. Reduction happens only at the boundary into a scalar context.

When you do want to reach into history explicitly, index the series. Indexing is newest-relative: [0] is the most recent bar and the index counts backward in time.

ScanLang
close[0]   // latest bar
close[1]   // one bar ago
close[5]   // five bars ago

Coercion and the None sentinel

  • A bool used as a number reduces to 1.0 / 0.0, so you can sum truthy conditions to count how many fired.
  • Noneis the “insufficient data” sentinel. It propagates through arithmetic and comparisons, and ultimately makes the indicator return None — i.e. no signal for that symbol — rather than crashing.
  • An out-of-range index returns None, following the same no-signal rule.
04ScanLang

Lexical elements

The lexer is small and predictable. This section covers the raw tokens — comments, literals, newlines, operators, and reserved words — that make up every program.

Comments

There are two line-comment forms and no block comments:

ScanLang
// line comment to end of line
# alternative line comment

Numbers and strings

Numbers come as 42 (integer), 2.5 (float), or .75 (the float 0.75). Integers and floats are kept distinct internallyso that the language preserves Python’s math semantics exactly.

Strings are double-quoted and support the escapes \\, \", \n, and \t — for example "above_both", which is how STATE values are written.

Newlines and line continuation

Newlines are statement terminators, with two exceptions. Inside ( … ) and [ … ] newlines are suppressed, so you can split a long call or boolean across lines as long as it is wrapped in parentheses. Inside { … } blocks, newlines separate the statements, params, or fields within. This is exactly why a multi-line boolean must be parenthesised:

ScanLang
output (
  close > ema(close, 8)
  and close > ema(close, 21)
  and close > ema(close, 50)
)

Operators and punctuation

Operators
->  :=  >=  <=  ==  !=  ..
+ - * / %   > <   ( ) [ ] { }   : , .

Keywords and reserved words

Keywords
anchor as bars by default detail else false find_first find_last for guard if key
let on output params requires select timeframe to true var visual when while within

The window-aggregation names are reserved too:

Reserved aggregations
avg_over count max_over min_over ratio_where run_length sum_over

And the timeframe literals are their own reserved tokens: daily, 1m, 5m, 15m, 30m, 1h.

Gotcha
A let or var name may not shadow a library function name. Writing let mean = … is an error, because meanis a built-in — pick a different binding name.
05ScanLang

Headers

Headers configure how an indicator is compiled and run before any logic executes. They are all optional, may each appear at most once, and must come before the first statement in the program.

timeframe

The timeframe header declares the primary timeframe the program operates on. When omitted, the default is daily. The accepted values are daily, 1m, 5m, 15m, 30m, and 1h.

ScanLang
timeframe 5m

output close > ema(close, 20)

requires … bars

The requires header is a warmup guard. If fewer than N bars are available, the indicator returns Noneinstead of producing a signal — this prevents windowed calculations from emitting noise before enough history has accumulated. The bar count can be a literal, or it can reference a declared param (optionally with arithmetic).

ScanLang
requires 50 bars
requires period bars
requires period + 10 bars
Tie requires to the param
When a window or lookback size comes from a param — e.g. count(n) or ema(close, period) — make requires reference that same param. A hardcoded requires 6 bars under a count(n) works at the default but silently makes the indicator never fire once the user raises n above the hardcoded floor.

params

The params header declares the tunable parameters an indicator exposes. Each entry has a name, a default value, and an optional type hint with an optional range. These hints feed the parameter schema that the UI surfaces, so users can adjust them without editing the program.

ScanLang
params {
  period = 50 : int(2..400)
  threshold = 1.5
  fast = 8 : int(2..50)
}

The type hint may be int, float, or number, each with an optional (low..high) range. A param without a hint (like threshold above) is inferred from its default value.

FormMeaning
name = 50Default only; type inferred.
name = 50 : intTyped, no range constraint.
name = 50 : int(2..400)Typed with a bounded range surfaced to the UI.
name = 1.5A float default (inferred as numeric).
06ScanLang

Statements

The body of a program is a sequence of statements. Statements bind values, guard against bad data, and declare the single output. Order matters: a binding must appear before it is used.

let — immutable binding

let introduces an immutable name. Once bound, a let cannot be reassigned. The subtlety is in what gets bound:

  • let x = close binds the series— it is reduced lazily wherever it is used, so it can still be indexed.
  • let x = volume / sma(volume, 20) evaluates the arithmetic eagerly and collapses to a single latest-bar scalar. It can no longer be indexed.
ScanLang
let fast = ema(close, 8)
let slow = ema(close, 21)

output fast > slow
Eager collapse vs. per-bar series
If you need a per-bar ratio (one value for each bar rather than just the latest), do not bind the arithmetic to a scalar let. Inline the expression inside a window body instead, so it is evaluated for every bar in the window.

var and :=

var declares a mutable binding, and :=reassigns it. Only vars can be reassigned — an attempt to reassign a let is an error. This is what makes accumulators possible.

ScanLang
var count = 0
count := count + 1

guard

guard checks a precondition. If the condition is false or None, the indicator immediately returns None and produces no signal. Guards are the idiom for bailing out on insufficient data or invalid prices.

ScanLang
guard close > 0
guard bar_count >= 50

output close > ema(close, 50)

output

Every program has exactly one output statement. The type of its value defines the indicator’s output type: a boolean signal, a number, or a state.

ScanLang
output fast > slow                          // boolean
output (close - ema(close, 50)) / close     // number
output select {  }                         // state
output is program-level only
outputmay not appear inside a block — it is always a top-level statement. The related statements detail, anchor, and visualare also statements, but they are covered in the Details & visuals section.
07ScanLang

Expressions & operators

Expressions combine literals, names, series, and calls with operators. Precedence determines how an unparenthesized expression groups; use parentheses whenever the intent is not obvious.

Operator precedence

From lowest binding (evaluated last) to highest binding (evaluated first):

Precedence (lowest to highest)
within
or
and
not
comparison   ( >  >=  <  <=  ==  != )
+  -
*  /  %
unary -
postfix      ( call(…)   index[…]   .member )
primary      ( literal | name | (expr) | select | on | window | find )

Arithmetic

The arithmetic operators are +, -, *, /, and %. Division or modulo by zero yields None rather than raising an error, which keeps a single bad bar from crashing a scan.

ScanLang
output (close - ema(close, 50)) / close * 100

Comparison

The comparison operators are >, >=, <, <=, ==, and !=. A comparison against missing data (None) is false. The equality operators == and != also compare states (strings).

Logical

and, or, and not are short-circuiting. For truthiness: None, 0, "" (empty string), and [] (empty list) are false; non-zero numbers and non-empty strings are true.

ScanLang
output not (close < ema(close, 50)) and volume > 1000

Indexing

series[i] indexes a series newest-relative: close[0] is the latest bar, close[1] is the previous one, and so on. An out-of-range index returns None. Only series can be indexed — indexing a scalar let raises Indexing requires a series.

Member access

The . operator reaches into ambient handles and bar objects:

ScanLang
session.first(5)     // first 5 bars of the current session
bar.time             // timestamp of the latest bar
bar.index            // session index of the latest bar
context.direction    // a scalar passthrough field from the engine context
bbar.close           // a field of a bar returned by find_first/find_last

Function calls

Function calls invoke the built-in library — for example ema(close, 50), highest(high, 6), and clamp(score, 0, 100).

No user-defined functions
The function library (covered in its own section) is the complete callable set. No other functions exist, and there is no way to define your own.
08ScanLang

select — state output

select is a multi-way branch that produces a state— a string label. It is the primary way to emit a categorical signal instead of a plain boolean or number.

Each arm is a condition -> value pair. The first arm whose condition is truthy wins. An optional default arm acts as the fallback when no other arm matches.

ScanLang
output select {
  close > sma(close, 20) and close > sma(close, 50) -> "above_both"
  close < sma(close, 20) and close < sma(close, 50) -> "below_both"
  default -> "between"
}

Derived state options

The set of possible states — stateOptions, surfaced to the chart and UI — is derived automatically from the string-literal arms. You do not declare them separately; the arms are the source of truth.

Homogeneous arms; no fallthrough
Arms must be homogeneous— either all string states or all numeric. With no matching arm and no default, the result is None.

Numeric select for scoring

select can also produce numbers instead of strings. Arms that return values like 1.0 / 0.0 are a common way to emit a score, branching on conditions to assign a numeric weight.

ScanLang
output select {
  close > sma(close, 50) and volume > sma(volume, 20) -> 1.0
  close > sma(close, 50)                               -> 0.5
  default                                              -> 0.0
}
09ScanLang

Window aggregations

A window aggregation evaluates a { body } block once per bar across a trailing window of N bars, then folds the per-bar results into a single value. They turn a question about one bar into a question about a stretch of recent history without an explicit loop.

The window size is optional. When you omit it, the body runs over every available bar; when you supply it, the body runs over only the most recent N bars. The body itself is an ordinary ScanLang expression — whatever it returns determines which aggregation forms are meaningful.

ScanLang
let bars_above = count(10) { close > ema(close, 8) }
let up_ratio   = ratio_where(20) { close > open }
let avg_vol    = avg_over(20) { volume }

There are two families: boolean reducers that count, measure runs, or take a fraction of truthy bars, and numeric reducers that sum, average, or take the extreme of a numeric body across the window.

FormBody returnsAggregates to
count(n) { … }boolnumber of truthy bars
ratio_where(n) { … }booltruthy fraction in [0,1]
run_length(n) { … }boollength of the trailing run of truthy bars
sum_over(n) { … }numbersum of finite values
avg_over(n) { … }numbermean of finite values
max_over(n) { … }numbermax finite value
min_over(n) { … }numbermin finite value

The numeric reducers ignore non-finite values, so a missing or undefined bar will not poison a sum_over or avg_over. run_lengthis especially useful for “how many bars in a row” conditions — for example the length of the current streak of higher closes.

Series resolve to the window bar
Inside a window body, a series resolves to the current window bar, not the latest bar. So count(10) { close > ema(close, 8) } tests each of the last 10 bars against that bar’s own EMA(8), which is exactly what you want. To reach the bar before the current window bar, use shift(x, 1) — not x[1], which stays newest-relative and would break out of the window.
10ScanLang

Sessions & multi-timeframe

Sessions and multi-timeframe features let a filter look beyond its own timeframe and bar — at the opening range, at a higher timeframe, or at a specific earlier bar in the session. They require the three-argument context form of an indicator, calculate(bars, params, context).

You never write that signature yourself. The compiler detects when a program reaches for context and flips the indicator into context mode (needs_context = true); the scan engine then supplies the context bundle at evaluation time. Using on(TF), session.*, or context.* triggers this. Plain bar.* and within do not.

Evaluating against another timeframe

on(TF) { expr }temporarily switches the ambient bars and session to the named timeframe’s data from the engine context, evaluates expr there, and restores the original timeframe afterward.

ScanLang
timeframe 5m
let or_high = on(30m) { highest(high, 1) within session.first(1) }
output close > or_high

Here a 5-minute scan reads the high of the first 30-minute bar of the session — the opening-range high — and checks whether price has broken above it. A missing timeframe yields None, so guard against it where it matters.

Restricting the ambient bars with within

within scope evaluates its left expression with the ambient bars limited to the given bar list. It is how you point an otherwise whole-history reducer at just the opening range, or at a slice of bars before some reference bar.

ScanLang
highest(high, 1) within session.first(1)
let avg_vol = mean(volume, 99) within bars_before(bbar, 10)

Session accessors

Session accessors expose the bars of the current trading session and a couple of scalars describing it.

AccessorReturns
session.first(n)first n bars of the current session (all if n omitted)
session.last(n)last n bars
session.barsall session bars (BARS)
session.bar_countnumber of session bars (NUMBER)

Bar handle & context passthrough

The bar handle reads the latest bar, and context.<field> reads any scalar the engine attached to the context bundle.

ScanLang
bar.time            // latest bar timestamp
bar.index           // latest bar session index
context.<field>     // any scalar field from the engine context (e.g. context.playbook_score)

Finding bars

find_first(scope) { predicate } returns the first bar in scope where the predicate is truthy; find_last returns the last. When nothing matches they return None, so test the result with exists(bbar) before reading a field off it with bbar.<field>.

ScanLang
let bbar = find_first(drop(session.bars, 1)) { close > or_high }
output exists(bbar) and bbar.close > or_high

Build the scope with helpers like bars_before(bar, n) (the n bars preceding a reference bar) and drop(bars, n) (the bar list with its first n entries removed). In the example above, drop(session.bars, 1) skips the opening bar so the breakout search starts from the second bar onward.

Why context mode matters
Context-mode filters cannot run on the live, single-timeframe fast path — they need the engine to assemble cross-timeframe and session data. A filter that uses on(daily), session.*, or context.* is expected to run where that bundle is available.
11ScanLang

Control flow

ScanLang has real imperative control flow — if, for, and while— for the cases where a declarative expression gets awkward. Every loop is bounded by a step budget of one million total iterations per evaluation, so a program always halts; exceeding the budget is a runtime error rather than a hang.

Control-flow blocks use { } and may contain only var (declare a mutable), := (assign), if, for, and while. The program-level statements — output, guard, detail, anchor, and visual— are not allowed inside a block.

if / else if / else

ScanLang
var trend = "flat"
if close > ema(close, 20) and close > ema(close, 50) {
  trend := "strong_up"
} else if close > ema(close, 50) {
  trend := "weak_up"
} else {
  trend := "down"
}
output trend

Branches chain with else if and an optional final else. Assign into a var declared before the block, then output it at program level once the block has run.

for

ScanLang
for i = 0 to 9 { x := x + close[i] }        // i runs 0..9 inclusive
for i = 9 to 0 by -1 {  }                  // descending; `by step` is optional

The bounds are inclusive on both ends. The optional by stepclause sets the stride — use a negative step to count down. A step of 0 is an error. The loop variable is scoped to the loop and is the natural index into a bar series, so close[i] and open[i] walk the bars as i advances.

while

ScanLang
var i = 0
while i < 10 { i := i + 1 }

A while loop repeats while its condition is truthy. The same step budget applies, so a condition that never becomes false will terminate the evaluation with a runtime error rather than spinning forever.

Prefer a loop over a long condition
When a condition would otherwise be a long, repetitive chain, a loop is clearer. This filter passes only when the last ten candles strictly alternate green and red:
ScanLang
var alt = true
for i = 0 to 8 {
  if (close[i] > open[i]) == (close[i+1] > open[i+1]) { alt := false }
}
output alt
12ScanLang

Details, anchors & visuals

These declarative statements enrich a result with evidence rows and chart drawings. The moment any of them appears, the indicator stops returning a bare scalar and instead returns an envelope: { value, details, visuals, anchors }. The score is still value; the rest is context for the UI.

detail — an evidence row

A detail attaches a labeled value to the result so a reviewer can see why a ticker scored the way it did.

ScanLang
detail "EMA 8" = fast as price key "ema8"
detail "Spread" = spread as price
detail "Broke out" = close > orh when exists(orh)   // conditional row

Three optional modifiers shape how and whether a row appears:

  • as <format> — the display format, such as price, percent, ratio, or number.
  • key "<id>"— a stable identifier for the row; defaults to a slug of the label when omitted.
  • when <expr>— emit the row only when the condition is truthy, so a row can appear conditionally.

anchor — a named value for visuals

An anchor stores a computed scalar under a name that a visual can later bind to with anchor(name). It is the bridge between a calculation and the drawing that depicts it.

ScanLang
anchor orh = highest(high, 6)

visual — a chart drawing

A visual declares something to draw on the chart. The kind (level, marker, …) follows the visual keyword; an optional when gates it at runtime; and the { } block holds key: value fields whose values are expressions.

ScanLang
visual level when spread > 0.5 {
  value: anchor(orh)
  label: "OR High"
  style: "dashed"
  color: "red"
}

Here the level is only drawn when spread > 0.5, and its value binds back to the orhanchor defined above — so the dashed red line lands exactly at the computed opening-range high.

One envelope, many rows
You can mix detail, anchor, and visualfreely in a single program. Each contributes to the same envelope — details build the evidence table, anchors feed values to visuals, and visuals become chart overlays — while output still determines the score.
13ScanLang

The function library

These are the ONLY callable functions — there are no others, and no way to define your own. Each delegates to a vetted TA.* helper where one exists, guaranteeing parity with the Python indicators.

Signature notation: series = SERIES, num = NUMBER, bars = BARS, any = any type, [num] = optional argument, num... = variadic.

Series transforms (series → series)

SignatureDescription
ema(series, num) -> seriesExponential moving average (SMA-seeded).
sma(series, num) -> seriesSimple moving average.
rolling_max(series, num) -> seriesRolling maximum over a window.
rolling_min(series, num) -> seriesRolling minimum over a window.
diff(series, [num]) -> seriesConsecutive differences (default period 1).
change(series, [num]) -> seriesAlias of diff().
pct_change(series, [num]) -> seriesPercent change over period (×100).
roc(series, [num]) -> seriesRate of change; alias of pct_change().
shift(series, [num]) -> seriesLag a series by n bars (default 1; negative leads).
vwap() -> seriesCumulative VWAP over the ambient bars.
true_range() -> seriesPer-bar true range.
atr(num) -> seriesAverage true range (SMA of true range).

Reducers (series → number)

SignatureDescription
highest(series, [num]) -> numMaximum finite value over the last n (or all).
lowest(series, [num]) -> numMinimum finite value over the last n (or all).
last(series) -> numLatest finite value of a series.
mean(series, [num]) -> numMean of the last n finite values (or all).
stdev(series, [num]) -> numPopulation standard deviation (optionally last n).
linreg_slope(series, [num]) -> numLeast-squares slope vs index (optionally last n).
average_range_pct(num) -> numMean (high - low) / close over the last period bars.

Crossovers (→ bool)

SignatureDescription
crossover(series|num, series|num) -> boolTrue if a crossed above b on the latest bar.
crossunder(series|num, series|num) -> boolTrue if a crossed below b on the latest bar.

Scalar math (number → number)

SignatureDescription
abs(num) -> numAbsolute value.
sqrt(num) -> numSquare root (None if negative).
pow(num, num) -> numbase ** exp.
tanh(num) -> numHyperbolic tangent.
min(num...) -> numMinimum of its numeric args.
max(num...) -> numMaximum of its numeric args.
clamp(num, num, num) -> numConstrain a value to [low, high].
lerp(num, num, num) -> numLinear interpolate a..b by t in [0, 1].

Bars & existence

SignatureDescription
drop(bars, [num]) -> barsDrop the first n bars (default 1).
bars_before(any, num) -> barsThe up-to-n primary bars before a given bar.
exists(any) -> boolTrue if the value (e.g. a found bar) is present.

String / state ops (→ state)

SignatureDescription
lower(any) -> stateLowercase a string (None → "").
upper(any) -> stateUppercase a string (None → "").
trim(any) -> stateStrip whitespace (None → "").

Bar fields & constants

Bar fields — each a SERIES aligned to the bars, auto-reducing to its latest value in scalar context: open high low close volume.

Constants (scalar NUMBER): live_close is the current price (live_pricechart_close close); bar_count is the number of bars passed to the indicator (len(bars)).

14ScanLang

The type system

ScanLang is statically checked before it runs. The checker reads each function’s signature from the library, so the editor’s hints and the runtime can never drift.

  • Lenient on SERIES vs NUMBER. A series used in a scalar position is auto-reduced at runtime, and a function declaring a NUMBER param accepts a series. You rarely fight the type checker over series/number.
  • Strict on names, functions, and arity. Unknown identifiers, calls to non-existent functions, and wrong argument counts are compile-time TypeCheckErrors with line/column.
  • No shadowing built-ins. let mean = ... errors because mean is a library function.
  • Homogeneous select arms. All-string or all-numeric; mixing them is an error.
  • Exactly one output is required.

The compiler also produces a CheckResult describing the program: output_type (boolean | number | state), needs_context, required_lookback, the parameter schema, and the derived state_options.

15ScanLang

Errors & diagnostics

All errors derive from ScanLangError and carry a message, line, col, and stage. There are four stages.

ErrorStageExamples
LexErrorlexunterminated string, unexpected character, mismatched bracket
ParseErrorparsemissing token, a header after a statement, malformed statement
TypeCheckErrortypecheckunknown name/function, wrong arity, indexing a scalar, mixed select arms
EvalErrorevalunknown function at runtime, loop step-budget exceeded

In the editor these surface as real-time inline diagnostics: the source is parsed and type-checked (no evaluation), debounced ~500ms, and returns 1-based line/col positions. A transient failure shows no diagnostics rather than blocking editing.

16ScanLang

How a program runs

A ScanLang indicator travels through five stages, from the editor to a score in the scan engine.

  • 1. Authoring.A CodeMirror-based editor provides highlighting, palette-driven autocomplete, hover docs, and live linting. All keywords, functions, signatures, and descriptions come from a generated palette kept in sync with the language library — so the UI is always in sync with the language.
  • 2. Compilation. compile_source(source) lexes → parses → type-checks → builds a calculate closure plus a CheckResult.
  • 3. Arity. The engine calls calculate(bars, params) for daily indicators or calculate(bars, params, context)for context-aware ones — chosen automatically from CheckResult.needs_context.
  • 4. Loading & registry. The plugin loader reads .scan files, compiles them, and drops the closure straight into the metric registry. The scan engine needs zero changes to score a ScanLang indicator. A lang discriminator selects ScanLang (.scan) vs legacy Python (.py); the two coexist.
  • 5. Result. calculate returns None | bool | number | str, or — when detail/anchor/visual are present — an envelope { value, details, visuals, anchors }.
17ScanLang

Gotchas

The handful of behaviours that catch authors off guard. Each is a quick fix once you know the rule.

Wrap multi-line booleans in ( )
Newlines terminate statements except inside parentheses/brackets.
Don't shadow library functions
A letname can’t shadow a library function (let mean = ... errors — use basis, avg, etc.).
let reduces eagerly to a scalar
let x = volume / sma(volume, 20)reduces EAGERLY to the latest bar’s scalar; it can’t be indexed afterwards. For a per-bar ratio, inline the expression in a window body.
Comparisons bound to let aren't series
A let bound to a comparison/arithmetic is a single scalar. let green = close > open then green[2]fails with "Indexing requires a series." For per-bar history, index the underlying series directly — close, open, high, low, volume are series, so close[0] is the latest bar and close[1] the previous.
Window series are window-relative
Inside a window/find body, series resolve to the current window bar. Use shift(x, 1)for the prior bar within the window — x[1] stays newest-relative.
Tie requires to param-driven lookbacks
If a window size comes from a param, write requires n bars (or requires period + 10 bars), not a hardcoded number — a fixed requires silently breaks the indicator once the user raises the param.
18ScanLang

Worked examples

Six complete programs, each isolating one capability. Copy any of them into the editor and adapt.

Minimal boolean

The shortest valid indicator: a single boolean output.

ScanLang
output close > ema(close, 50)

Parameterised EMA stack with evidence

A tunable EMA stack that emits price-pane details, an anchor, and a conditional visual marker.

ScanLang
timeframe 5m
requires 50 bars
params {
  period = 50 : int(2..400)
}

let eF = ema(close, 8)
let eM = ema(close, 21)
let eS = ema(close, period)
let spread = max(eF, eM, eS) - min(eF, eM, eS)

output (eF > eM and eM > eS)

detail "EMA 8"  = eF as price key "ema8"
detail "EMA 21" = eM as price key "ema21"
detail "EMA 50" = eS as price key "ema50"
detail "Spread" = spread as price

anchor hi = max(eF, eM, eS)
visual level when spread > 0.5 {
  value: anchor(hi)
  label: "High EMA"
  style: "dashed"
}

State output

A select that classifies each bar into one of three named states.

ScanLang
output select {
  close > sma(close, 20) and close > sma(close, 50) -> "above_both"
  close < sma(close, 20) and close < sma(close, 50) -> "below_both"
  default -> "between"
}

Window aggregation

Count how many of the last lookback bars closed above their fast EMA, then gate on volume.

ScanLang
params { lookback = 10 : int(2..100) }
requires lookback bars
let bars_above = count(lookback) { close > ema(close, 8) }
output bars_above >= 7 and volume > mean(volume, 20) * 1.5

Loop-based candle pattern

A for loop that checks the last ten candles strictly alternate green and red.

ScanLang
// Last 10 candles strictly alternate green/red
var alt = true
for i = 0 to 8 {
  if (close[i] > open[i]) == (close[i+1] > open[i+1]) { alt := false }
}
output alt

Multi-timeframe opening-range breakout

A 3-arg context indicator: read the opening 30m range on a higher timeframe, then look for the first session bar to break it.

ScanLang
timeframe 5m
let or_high = on(30m) { highest(high, 1) within session.first(1) }
let bbar = find_first(drop(session.bars, 1)) { close > or_high }
let has_breakout = exists(bbar)

output select {
  has_breakout -> 1.0
  default -> 0.0
}

detail "Opening 30m High" = or_high as price
detail "Breakout Close" = bbar.close as price when has_breakout
19ScanLang

Changelog

Every change to the ScanLang language, its function library, or its editor tooling is recorded here, newest first. Versions follow semantic versioning — a breaking grammar or semantics change bumps the major, a backward-compatible addition bumps the minor, and a fix or clarification bumps the patch.

Existing saved indicators keep scoring identically across patch and minor releases; only a major release can change how a previously valid program behaves.

v1.0.0June 18, 2026

First stable release of ScanLang — the safe, declarative screening language behind the AI strategy builder, with parity-by-construction against the Python scan engine.

  • AddedCore language: headers (timeframe, requires, params), let / var bindings, guard, and exactly-one output, ending in a boolean, number, or state verdict.
  • AddedThe five-type value model (SERIES, NUMBER, BOOL, STATE, BARS) with the automatic reduction rule and newest-relative series indexing.
  • Addedselect multi-way branching for categorical state and numeric scoring outputs.
  • AddedWindow aggregations — count, ratio_where, run_length, sum_over, avg_over, max_over, min_over — over trailing per-bar bodies.
  • AddedSessions and multi-timeframe: on(TF), within scopes, session accessors, the bar/context handles, and find_first / find_last bar search.
  • AddedImperative control flow (if / else if / else, for, while) under a one-million-iteration step budget so every program is guaranteed to halt.
  • AddedDeclarative detail, anchor, and visual statements that enrich a result with evidence rows and chart drawings via the result envelope.
  • AddedA vetted function library (EMAs, reducers, crossovers, scalar math, bar/series helpers) that delegates to the same TA helpers as Python indicators.
  • AddedStatic type checking with line/column diagnostics plus CodeMirror editor tooling — palette-driven autocomplete, hover docs, and live linting.