ScanLang
v1.0.0ScanLang 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.
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 > slowHeaders, then a body, ending in exactly one output.
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.
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 > slowEverything else in the language — windows, sessions, loops, details, visuals — layers on top of this core shape without changing it.
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.
program := comment* header* statement*
header := timeframe | requires | params // each at most once, before any statementThe 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.
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.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.
| Type | Python representation | Examples |
|---|---|---|
| SERIES | a list aligned to the bars | close, ema(close, 8) |
| NUMBER | int / float | 42, 1.5, bar_count |
| BOOL | bool | true, close > open |
| STATE | str | "above_both" |
| BARS | list of bar dicts | session.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.
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.
close[0] // latest bar
close[1] // one bar ago
close[5] // five bars agoCoercion 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.
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:
// line comment to end of line
# alternative line commentNumbers 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:
output (
close > ema(close, 8)
and close > ema(close, 21)
and close > ema(close, 50)
)Operators and punctuation
-> := >= <= == != ..
+ - * / % > < ( ) [ ] { } : , .Keywords and reserved words
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 withinThe window-aggregation names are reserved too:
avg_over count max_over min_over ratio_where run_length sum_overAnd the timeframe literals are their own reserved tokens: daily, 1m, 5m, 15m, 30m, 1h.
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.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.
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).
requires 50 bars
requires period bars
requires period + 10 barscount(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.
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.
| Form | Meaning |
|---|---|
name = 50 | Default only; type inferred. |
name = 50 : int | Typed, no range constraint. |
name = 50 : int(2..400) | Typed with a bounded range surfaced to the UI. |
name = 1.5 | A float default (inferred as numeric). |
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 = closebinds 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.
let fast = ema(close, 8)
let slow = ema(close, 21)
output fast > slowlet. 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.
var count = 0
count := count + 1guard
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.
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.
output fast > slow // boolean
output (close - ema(close, 50)) / close // number
output select { … } // stateoutputmay 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.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):
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.
output (close - ema(close, 50)) / close * 100Comparison
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.
output not (close < ema(close, 50)) and volume > 1000Indexing
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:
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_lastFunction calls
Function calls invoke the built-in library — for example ema(close, 50), highest(high, 6), and clamp(score, 0, 100).
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.
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.
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.
output select {
close > sma(close, 50) and volume > sma(volume, 20) -> 1.0
close > sma(close, 50) -> 0.5
default -> 0.0
}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.
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.
| Form | Body returns | Aggregates to |
|---|---|---|
count(n) { … } | bool | number of truthy bars |
ratio_where(n) { … } | bool | truthy fraction in [0,1] |
run_length(n) { … } | bool | length of the trailing run of truthy bars |
sum_over(n) { … } | number | sum of finite values |
avg_over(n) { … } | number | mean of finite values |
max_over(n) { … } | number | max finite value |
min_over(n) { … } | number | min 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.
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.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.
timeframe 5m
let or_high = on(30m) { highest(high, 1) within session.first(1) }
output close > or_highHere 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.
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.
| Accessor | Returns |
|---|---|
session.first(n) | first n bars of the current session (all if n omitted) |
session.last(n) | last n bars |
session.bars | all session bars (BARS) |
session.bar_count | number 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.
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>.
let bbar = find_first(drop(session.bars, 1)) { close > or_high }
output exists(bbar) and bbar.close > or_highBuild 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.
on(daily), session.*, or context.* is expected to run where that bundle is available.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
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 trendBranches 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
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 optionalThe 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
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.
var alt = true
for i = 0 to 8 {
if (close[i] > open[i]) == (close[i+1] > open[i+1]) { alt := false }
}
output altDetails, 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.
detail "EMA 8" = fast as price key "ema8"
detail "Spread" = spread as price
detail "Broke out" = close > orh when exists(orh) // conditional rowThree optional modifiers shape how and whether a row appears:
as <format>— the display format, such asprice,percent,ratio, ornumber.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.
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.
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.
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.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)
| Signature | Description |
|---|---|
| ema(series, num) -> series | Exponential moving average (SMA-seeded). |
| sma(series, num) -> series | Simple moving average. |
| rolling_max(series, num) -> series | Rolling maximum over a window. |
| rolling_min(series, num) -> series | Rolling minimum over a window. |
| diff(series, [num]) -> series | Consecutive differences (default period 1). |
| change(series, [num]) -> series | Alias of diff(). |
| pct_change(series, [num]) -> series | Percent change over period (×100). |
| roc(series, [num]) -> series | Rate of change; alias of pct_change(). |
| shift(series, [num]) -> series | Lag a series by n bars (default 1; negative leads). |
| vwap() -> series | Cumulative VWAP over the ambient bars. |
| true_range() -> series | Per-bar true range. |
| atr(num) -> series | Average true range (SMA of true range). |
Reducers (series → number)
| Signature | Description |
|---|---|
| highest(series, [num]) -> num | Maximum finite value over the last n (or all). |
| lowest(series, [num]) -> num | Minimum finite value over the last n (or all). |
| last(series) -> num | Latest finite value of a series. |
| mean(series, [num]) -> num | Mean of the last n finite values (or all). |
| stdev(series, [num]) -> num | Population standard deviation (optionally last n). |
| linreg_slope(series, [num]) -> num | Least-squares slope vs index (optionally last n). |
| average_range_pct(num) -> num | Mean (high - low) / close over the last period bars. |
Crossovers (→ bool)
| Signature | Description |
|---|---|
| crossover(series|num, series|num) -> bool | True if a crossed above b on the latest bar. |
| crossunder(series|num, series|num) -> bool | True if a crossed below b on the latest bar. |
Scalar math (number → number)
| Signature | Description |
|---|---|
| abs(num) -> num | Absolute value. |
| sqrt(num) -> num | Square root (None if negative). |
| pow(num, num) -> num | base ** exp. |
| tanh(num) -> num | Hyperbolic tangent. |
| min(num...) -> num | Minimum of its numeric args. |
| max(num...) -> num | Maximum of its numeric args. |
| clamp(num, num, num) -> num | Constrain a value to [low, high]. |
| lerp(num, num, num) -> num | Linear interpolate a..b by t in [0, 1]. |
Bars & existence
| Signature | Description |
|---|---|
| drop(bars, [num]) -> bars | Drop the first n bars (default 1). |
| bars_before(any, num) -> bars | The up-to-n primary bars before a given bar. |
| exists(any) -> bool | True if the value (e.g. a found bar) is present. |
String / state ops (→ state)
| Signature | Description |
|---|---|
| lower(any) -> state | Lowercase a string (None → ""). |
| upper(any) -> state | Uppercase a string (None → ""). |
| trim(any) -> state | Strip 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_price → chart_close → close); bar_count is the number of bars passed to the indicator (len(bars)).
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 becausemeanis 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.
Errors & diagnostics
All errors derive from ScanLangError and carry a message, line, col, and stage. There are four stages.
| Error | Stage | Examples |
|---|---|---|
LexError | lex | unterminated string, unexpected character, mismatched bracket |
ParseError | parse | missing token, a header after a statement, malformed statement |
TypeCheckError | typecheck | unknown name/function, wrong arity, indexing a scalar, mixed select arms |
EvalError | eval | unknown 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.
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 acalculateclosure plus aCheckResult. - 3. Arity. The engine calls
calculate(bars, params)for daily indicators orcalculate(bars, params, context)for context-aware ones — chosen automatically fromCheckResult.needs_context. - 4. Loading & registry. The plugin loader reads
.scanfiles, compiles them, and drops the closure straight into the metric registry. The scan engine needs zero changes to score a ScanLang indicator. Alangdiscriminator selects ScanLang (.scan) vs legacy Python (.py); the two coexist. - 5. Result.
calculatereturnsNone | bool | number | str, or — when detail/anchor/visual are present — an envelope{ value, details, visuals, anchors }.
Gotchas
The handful of behaviours that catch authors off guard. Each is a quick fix once you know the rule.
letname can’t shadow a library function (let mean = ... errors — use basis, avg, etc.).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.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.shift(x, 1)for the prior bar within the window — x[1] stays newest-relative.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.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.
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.
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.
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.
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.5Loop-based candle pattern
A for loop that checks the last ten candles strictly alternate green and red.
// 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 altMulti-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.
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_breakoutChangelog
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.
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.