1. Execution model2. Lexical structure3. Grammar4. Types and values5. Statements, scope and state6. Expressions7. Builtin functions8. Time model and sources9. Input declarations10. Draw functions11. Strategy engine12. Result object13. Errors14. Versioning and change historyAppendix A — non-normative implementation notes

theta-script language specification

Version 2.4.0 (LANG_VERSION in js/src/names.js; every conformance fixture records the version it was generated under. The npm package version is independent — theta-script 3.x on npm ships this language version compiled from the Rust core, see §14)

A small language for chart studies and trade-signal scripts, executed once per bar. This document is the normative definition; the Rust core (rust/theta-script/) is the engine every shipped runtime wraps, the pure-JS interpreter (js/src/) is a retained independent implementation, and the fixtures under conformance/ are the executable contract a port must satisfy.

study("Cross Strategy", overlay=true)
fast = ema(close, 9)
slow = ema(close, 21)
plot(fast, color="#22d3ee")
plot(slow, color="#f59e0b", width=2)
var entries = 0
if crossover(fast, slow)
    entries := entries + 1
strategy.buy(crossover(fast, slow), 10, trailing=2)
strategy.sell(crossunder(fast, slow), 10)
alertcondition(crossover(fast, slow), message="cross up")

Design constraints that keep the language portable and safe on untrusted input: no recursion, no unbounded loops, no I/O, no aggregate data structures. Work per run is bounded by bars × statements × loop-limit, and the per-bar model evaluates incrementally — a live tick appends one bar of work, which is what a streaming backend wants.

Version 1 (the whole-series model) is superseded: v2 reproduces v1 scripts byte-for-byte (the v1 conformance corpus regenerated identically under this engine) with three deliberate changes, listed in §14.

1. Execution model

A script runs against an ordered array of bars and produces one result object. Bars are:

{ date, open, high, low, close, volume }

date is milliseconds since the Unix epoch; volume may be absent (read as 0). Let n be the bar count.

The script body executes once per bar, i = 0 … n-1, top to bottom. Sources (close, time, …) read bar i; plain assignments recompute per bar; var declarations initialize on the first bar and persist; draw calls contribute bar i's slice of their output; indicator builtins advance per-call-site incremental state. After each bar, every top-level variable's current value is committed to its history (read via x[k]).

With n = 0, the body runs exactly once as a declaration pass: every source reads na, per-bar output arrays stay empty, but declarations (plots, inputs, study(), strategy presence) are still recorded so hosts can render controls for an empty tape.

Host options beyond bars (all optional): inputs (override map, §9), timezone (IANA session zone, default America/New_York; 'local' resolves to the host zone), session ({ open: 'HH:MM', close: 'HH:MM' }, default 09:30/16:00). Execution is deterministic: same script + bars + options ⇒ identical result on any host.

2. Lexical structure

3. Grammar

program    = { NL } , { statement } ;
statement  = "var" IDENT "=" expr
           | IDENT ":=" expr
           | IDENT "=" expr
           | IDENT "(" [ IDENT { "," IDENT } ] ")" "=>" fnbody   (* top level only *)
           | "if" expr block { "else" "if" expr block } [ "else" block ]
           | "for" IDENT "=" expr "to" expr [ "by" expr ] block
           | "while" expr block
           | "switch" expr NL INDENT { arm } DEDENT
           | "break" | "continue"
           | expr ;
arm        = [ expr ] "=>" statement NL ;      (* single-line arm; bare "=>" is the default *)
fnbody     = expr | block ;                    (* block's last statement must be an expr *)
block      = NL INDENT { statement } DEDENT ;

expr       = ternary ;
ternary    = or_expr [ "?" expr ":" expr ] ;   (* right-associative *)
or_expr    = and_expr { "or" and_expr } ;
and_expr   = eq_expr { "and" eq_expr } ;
eq_expr    = rel_expr { ( "==" | "!=" ) rel_expr } ;
rel_expr   = add_expr { ( "<" | "<=" | ">" | ">=" ) add_expr } ;
add_expr   = mul_expr { ( "+" | "-" ) mul_expr } ;
mul_expr   = unary { ( "*" | "/" | "%" ) unary } ;
unary      = "-" unary | "not" unary | postfix ;
postfix    = primary { "[" expr "]" } ;
primary    = NUMBER | STRING | "(" expr ")"
           | IDENT "(" [ arg { "," arg } ] ")" | IDENT ;
arg        = IDENT "=" expr | expr ;

Statements on one line need a newline terminator; statements ending in a block are self-terminating. Binary operators are left-associative; the ternary condition parses at or_expr level, branches are full expr.

Validation (parse-time errors): declaration calls (§10's draw functions, input.*, study, strategy.buy/sell/config, alertcondition, line.new/label.new/box.new, security) must appear in top-level statements — never inside if/for/while/switch blocks or function bodies, and never inside a while condition or a switch arm test (those expressions run repeatedly or conditionally, which would break every once-per-bar declaration invariant; if conditions, for bounds and switch subjects evaluate exactly once per bar and are fine). break/continue must appear inside a loop body. security's argument expressions may not contain any of those top-level-only calls (so security never nests). Function definitions are top-level only, may not be recursive (directly or mutually), and take positional arguments only; parameter lists are comma-separated with no trailing comma and no duplicate names. Duplicate keyword arguments in any call are an error. Expression nesting is capped (reference: 500 levels) so parser and evaluator recursion stay bounded on adversarial input.

4. Types and values

Per-bar values: number (IEEE-754 binary64; booleans are 1/0, na is NaN), string, plot-ref (opaque, returned by plot()/hline(), accepted by fill()), and array (§7a). Arrays are mutable reference values: assignment and function passing share the same array; == compares reference identity; arrays are truthy; in numeric contexts they read as NaN, and as infopanel values they display as na.

Truthiness: not 0, not NaN, not the empty string.

5. Statements, scope and state

History e[k]: k is a per-bar scalar (rounded; non-number errors, may vary bar to bar). For a plain variable or source, x[k] reads the committed value from bar i-k (x[0] is the current value); out-of-range or negative k yields na. For any other expression, the engine buffers the expression's per-bar values at that call site; bars where the expression didn't execute read na.

6. Expressions

Eagerness rule: expressions always evaluate all their operands — ?: evaluates both branches and selects, and/or evaluate both sides (no short-circuit), iff evaluates all three arguments. Only statement blocks (if/for) execute conditionally. In a call, keyword arguments evaluate before positional arguments (each group left to right) — side-effectful arguments observe that order. Consequence: indicator builtins inside expressions advance every bar and stay gap-free; builtins inside if/for bodies only advance when the block runs (their windows skip the other bars) — put indicators in expressions, decisions in statements.

opsemantics
+IEEE addition — if either operand is a string, concatenation (numbers render via the ECMAScript shortest-round-trip algorithm; NaN renders "NaN", arrays render "[array]"). With no string operand, non-number operands (arrays, plot-refs) read as NaN
- * /IEEE arithmetic; x/0 ±Infinity, 0/0 NaN
%JS remainder — sign of the dividend (-5 % 3 = -2), never floored modulo
< <= > >=1/0; comparisons involving NaN are 0
== !=strict same-type equality; 100 == "100" is 0; NaN equals nothing
and or1/0 by truthiness, both sides evaluated
not, unary -logical (1/0) / numeric negation

Non-+/==/!= operators on strings are undefined behavior.

7. Builtin functions

Instantiated per call site, fed once per executed bar. Length/period arguments are validated (max(1, round(p)), non-number errors with <name>: expected a number, periods above 100 000 error — bounded work and allocation per call site) and locked at the first call — a length that changes between bars is an error. Series-fed builtins coerce non-number series values to NaN before feeding their stream (a string would otherwise corrupt accumulators). "NaN-poisoned window" = output is NaN while any of the last p inputs is NaN and before the first full window.

Series-fed — fn(series, period):

functiondefinition
sma wmasimple / linearly-weighted mean (weights 1..p, newest p); non-finite values poison the window
emae ← α·v + (1-α)·e, α = 2/(p+1), seeded with the first finite value; NaN inputs emit NaN without advancing
wildersema with α = 1/p
dema tema2e₁−e₂, 3e₁−3e₂+e₃ over stacked EMAs
tmasma(sma(v, ⌈(p+1)/2⌉), ⌊p/2⌋+1)
hullwma(2·wma(v,⌊p/2⌋) − wma(v,p), round(√p))
rsiWilder's RSI over consecutive finite values (non-finite inputs are gaps — carried past, like ema); first p deltas seed the averages, then avg ← (avg·(p-1)+x)/p; flat → 50, lossless → 100
stdevpopulation σ via rolling Σv, Σv²; poisoned by non-finite window values (±Infinity would corrupt the rolling sums permanently); variance ≤ (Σv²/p)·1e-12 reads 0
sumrolling sum, poisoned by non-finite window values like stdev
highest lowestrolling max / min, NaN-poisoned (±Infinity is orderable and passes through)
highestbars lowestbarsbars back to the window extreme (0 = current; ties → most recent)
change(s, p=1) / moms[i] − s[i−p], NaN for i < p
offsets[i−p], NaN for i < p
roc100·(s[i]−s[i−p])/s[i−p], NaN during warmup or zero base
linregleast-squares fit over the window (x = 0..p−1), evaluated at the newest bar; NaN-poisoned
rising falling1 iff strictly rose/fell on each of the last p steps; NaN breaks the run
crossover(a,b)1 iff not the first call ∧ a > b ∧ previously a ≤ b (NaN comparisons false)
crossunder crossmirror; either — both underlying streams advance every bar
cumrunning sum over the whole tape; NaN and non-numbers add 0
valuewhen(c,s)s at the most recent truthy c (inclusive); NaN before the first
barssince(c)bars since truthy c (0 on the bar); NaN before the first
correlation(a,b,p)Pearson r over the window (population moments); NaN-poisoned; a zero-variance side reads NaN
percentile(s,p,q) / median(s,p)sorted-window linear-interpolation percentile (q default 50, clamped to [0,100], locked); median = percentile(s,p,50); NaN-poisoned
alma(s,p,offset,sigma)Arnaud Legoux MA — gaussian weights centered at offset·(p−1) (default 0.85), width p/sigma (default 6); parameters locked; NaN-poisoned

Bar-fed (read OHLCV directly): tr() (bar 0 high−low, else max(h−l, |h−pc|, |l−pc|)), atr(p) = wilders(tr, p), stoch(p) = 100·(c−ll)/(hh−ll) over the bar range (flat range → NaN), mfi(p) (typical-price × volume flows, RSI-style ratio; flat → 50), obv() (cumulative signed volume from 0), vwap([src]) (Σ price·volume / Σ volume, src default hlc3, reset when the session-timezone calendar day changes), pivothigh(l, r) / pivotlow(l, r) (the center value of an l+1+r window once it is the strict extreme, emitted when confirmed, else NaN).

Bar-fed, round 2 (all lengths/parameters locked at the first call):

Elementwise/stateless: abs sqrt log exp round floor ceil sign pow, sin cos tan asin acos atan and atan2(y, x) (IEEE, transcendental tolerance applies), min max avg (variadic, pairwise left reduce; at least one argument or it errors, non-number arguments read as NaN), nz(s, v=0), na(s), iff(c, a, b) (a missing selected branch reads na), tostring(x[, precision]) (precision clamps 0–8 → toFixed; otherwise shortest round-trip; NaN → "NaN"), and the §8 time functions. The pre-bound constant pi is IEEE-754 π.

Float determinism: accumulation orders above are normative, so arithmetic is bit-reproducible; transcendental primitives get 1e-9 relative tolerance in conformance (see conformance/README.md).

7a. Arrays (array.*)

array.new([size], [initial]) returns a new array of size elements (default 0) filled with initial (default na). Sizes above 100 000 error, and array.push/array.unshift error once an array holds 100 000 elements — an unbounded allocation is a host OOM no error path survives. Persistence follows variable semantics: var a = array.new() creates one array on the first bar and keeps it; a plain a = array.new() creates a fresh array every bar. Elements are any scalar value.

functiondefinition
array.push(a, v) / array.unshift(a, v)append / prepend (missing v is na); returns 0
array.pop(a) / array.shift(a)remove and return the last / first element; na when empty
array.get(a, i)element at rounded index i; na when out of range (like history access)
array.set(a, i, v)write at rounded index; out-of-range is a runtime error
array.size(a)element count
array.first(a) / array.last(a)na when empty
array.clear(a)empty the array; returns 0
array.sum/avg/min/max(a)numeric aggregates skipping non-numbers and na; na when nothing remains

Passing a non-array where one is expected is a runtime error. tostring(array) renders "[array]"; other coercions are UB.

7b. Strings (str.*)

Stateless; every argument is first coerced to a string with the + concatenation rules (shortest round-trip numbers, "NaN", "[array]").

functiondefinition
str.format(fmt, a0, a1, …)replaces {0}, {1}, … with the coerced arguments; an index past the argument list renders ""; anything not matching {digits} is literal
str.contains(s, sub)1/0 substring test
str.replace(s, from, to)replaces all occurrences; empty from returns s unchanged
str.upper(s) / str.lower(s)Unicode default case mapping
str.length(s)length in UTF-16 code units
str.split(s, sep)array of the pieces; empty sep yields [s] (whole string — per-character splitting is deliberately unspecified)

7c. Multi-timeframe: security(tf, expr)

security("5m", …) evaluates an expression against the same symbol's bars aggregated to a higher timeframe, built internally from the input tape (no host data feed involved). tf is "<count>m", "<count>h", "1d" or "1w" (locked at the first call; the count must be ≥ 1, and other counts for d/w are errors). Bars bucket by ⌊time / bucket-ms⌋ for minute/hour timeframes, or by the §7d session-day / week anchor keys for "1d"/"1w".

Confirmed buckets only (non-repainting): while a bucket is still filling, security keeps returning the value computed from the last completed bucket — NaN until the first bucket completes. When a new bucket opens, the finished one (open = first bar's open, high/low = extremes, close = last close, volume = summed, time = first bar's time) is appended to the aggregate series and expr is re-evaluated at its last index.

Inside expr, sources read the aggregate bar (bar_index is the aggregate index; hl2 hlc3 ohlc4 derive from it), and history/indicator builtins run over the aggregate series in an isolated state contextema(close, 9) inside and outside security are independent streams. §3's validation keeps declarations (and nested security) out of expr; user-function calls inside expr are fine.

7d. Anchored vwap

vwap([src], [anchor]) — cumulative Σ price·volume / Σ volume, src default hlc3, reset when the anchor period rolls over in the session timezone: "session" (default; calendar day), "week" (Monday-start: key = ⌊(wall-days-since-epoch + 3) / 7⌋), or "month" (calendar month). A string first argument is the anchor with the default source — vwap("week") is weekly vwap over hlc3. The anchor is locked at the first call like every other stream parameter; a non-string anchor argument reads as "session".

8. Time model and sources

All calendar semantics use the session timezone (§1); implementations need an IANA tz database. timestamp("YYYY-MM-DD[ HH:MM[:SS]]") (space or T) and timestamp(y, m, d, h, mi) read wall time in that zone; the wall→epoch conversion is the two-pass offset refinement (t₁ = wall − off(wall), t = wall − off(t₁)). Calendar extractors year month dayofmonth dayofweek hour minute are elementwise over ms timestamps in the session zone (dayofweek 0=Sunday); NaN in ⇒ NaN out.

Pre-bound sources: open high low close volume, hl2 hlc3 ohlc4, bar_index, time (ms), and scalars current_datetime (latest bar's time), date_today (session-timezone midnight of that day), market_open / market_close (session bounds on that day), barstate.isfirst / barstate.islast (1 on bar 0 / bar n−1), plus the strategy state of §11. All "now" scalars derive from the latest bar, never the host clock; with n = 0 they are NaN.

9. Input declarations

input.int / input.float (alias input) / input.bool / input.string / input.time. Declarations execute on the first bar only (their argument expressions must be constants); each call site appends a record to result.inputs and returns the locked value on every bar.

Labels come from title= or the second positional string, else "Input <k>"; duplicate labels get " (2)", " (3)"… suffixes, bumped until the key is actually unused — a literal label "X (2)" never collides with the generated suffix for a later duplicate "X" (the record's key is the override-lookup key). Values: the typed host override under that key, else the default — int rounds then clamps to minval/maxval (clamping applies to defaults too); bool normalizes to 1/0; string takes options="a,b,c" (comma-separated) and falls back to the default when the override isn't listed; time parses per §8 (record carries the raw string as text). Record layout: { key, label, type, default, value, …type-specific…, tooltip } with type-specific keys minval maxval step (int/float), options (string), text (time).

10. Draw functions

Top-level-only calls, executed once per bar: the record is created on the first bar and then fed per-bar data. Style arguments are read on the first bar; the ones marked locked below error if they change on a later bar (plot color/width/title/style/linestyle, hline value, fill's plot-ref-vs-number classification, plotshape/plotbuy/plotsell color and price kind, alertcondition title/message, strategy order kwargs validated per call). The rest — hline color/width/title, fill color/opacity, plotshape shape/location/size, bgcolor opacity, infopanel title/color/precision — are first-bar-wins: later values are silently ignored, not errors. Colors are opaque strings; the default plot palette cycles #22d3ee #f59e0b #a78bfa #22c55e #ec4899 #38bdf8.

Draws-nothing rule: if after the run plots shapes trades panel barColors bgColors lines labels boxes are all empty AND no alert or strategy call exists, the script errors.

11. Strategy engine

strategy.buy(when, [qty], qty= qty_type= limit= stop= expires= stop_loss= take_profit= trailing=) and strategy.sell(...) place orders on bars where when is truthy. Without limit=/stop= the order fills at the close of the signal bar. The engine maintains one netted position: an opposite-side fill first closes open quantity at the position's volume-weighted average price and realizes P&L; any remainder opens the other way.

Sizing (qty defaults 1; a non-positive/NaN resolved quantity is ignored): qty_type is "shares" (default; qty is the share count), "cash" (qty / fill-price shares), or "percent_of_equity" (equity·qty% / fill-price, equity = capital equity at the current close). Any other value is a runtime error.

Pending orders (limit= price or stop= price — both at once is an error; a non-finite price drops the order entirely rather than silently degrading to a market fill — a warmup-NaN limit must not buy at market): the call registers a working order per call site; a later signal from the same call replaces it. From the next bar on, before the body runs, a limit buy triggers when low ≤ limit and fills at min(open, limit) (sell: mirror above the market); a stop-entry buy triggers when high ≥ stop and fills at max(open, stop) (sell: mirror). expires= k (rounded, min 1) cancels an order still working more than k bars after placement. Protective-exit checks run before pending fills on each bar. Quantities resolve at trigger time.

Costs (strategy.config, §10 — defaults: capital 10 000, zero costs): slippage is a price offset that always works against the fill (buys fill higher, sells lower); each fill (entry and exit) is charged commission_cash + commission_percent%·qty·price, accumulated in summary.commissions (never in per-trade pnl). Costs clamp at 0 (a negative slippage would be a scriptable rebate) and non-positive initial_capital is ignored. pyramiding caps same-direction entries per position episode (min 1; default unlimited); capped entries are dropped.

Protective exits are price offsets from the average entry, captured at entry: on each later bar, before the body runs, the engine checks — stop-loss / trailing stop first (long: low ≤ stop exits at min(open, stop); trailing stop = favorable extreme − offset), then take-profit (long: high ≥ target exits at max(open, target)). Stop beats target inside one bar. The trailing extreme starts at the entry fill price and folds in the high/low of completed bars after the entry bar only — the entry bar's own range happened before (or straddling) the fill and never arms the trail. A pyramiding add ratchets the extreme toward its fill price but never loosens it.

Script-visible state (start-of-bar, after protective exits, marked at the current close): strategy.position_size (±, 0 flat), strategy.avg_price (na flat), strategy.open_pnl, strategy.realized_pnl, strategy.equity, strategy.trades, strategy.wins, strategy.losses.

The result's strategy object (null when no strategy call exists) carries the ledger — trades: [{ side, entryBar, entryPrice, exitBar, exitPrice, qty, pnl, reason: 'signal'|'stop'|'target' }] (prices are post-slippage; pnl excludes commissions), open (position or null) — and a summary:

Fills also emit plotbuy/plotsell-shaped marker records (color null, priceSource "close") into result.trades, so hosts render strategies with the marker pipeline.

12. Result object

{
  title, overlay, description,
  plots:  [{ key, title, color, width, style, lineStyle, colorSpan, widthSpan, values }],
  fills:  [{ a, b, color, opacity }],
  shapes: [{ values, shape, location, color, size }],
  trades: [{ values, qty, side, color }],
  panel:  [{ title, value, color, precision }],
  barColors: [ (string|null)[] ],
  bgColors:  [{ colors, opacity }],
  inputs: [ §9 records ],
  alerts: [{ title, message, values, messages? }],
  lines:  [{ x1, y1, x2, y2, color, width, lineStyle }],
  labels: [{ x, y, text, color, textColor, style, size }],
  boxes:  [{ x1, y1, x2, y2, color, bgColor, width }],
  strategy: §11 object | null,
  error: string | null
}

All per-bar arrays have length n (lines/labels/boxes are object pools, §10). JSON wire encoding: NaN → null, ±Infinity → "Infinity"/"-Infinity", −0 → 0; absent optional record keys are omitted (never emitted as null) (see conformance/README.md).

13. Errors

All-or-nothing per run: on the first lexical/parse/validation/runtime error, error carries the message (prefixed line <k>: where known) and plots fills shapes trades panel barColors bgColors alerts lines labels boxes are emptied, strategy is null; title overlay description inputs keep what was recorded. Message text is informative; the error conditions are normative: §2–§3 syntax and validation errors (including break/continue outside a loop, top-level-only calls in blocks, and top-level-only calls inside security arguments); unknown variable/function; reserved-word misuse; non-number history offset or loop bound; zero loop step; loop-limit breach; recursion; wrong user-fn arity; assigning to built-ins or var names with =; := to undeclared names; locked style/length/parameter changes (including security's timeframe and vwap's anchor); a malformed security timeframe; an order with both limit= and stop=; an unknown qty_type; series→scalar rule violations (fill arity); duplicate keyword arguments or function parameters; expression nesting beyond the cap; a period above 100 000; an array size or growth beyond 100 000 elements; min/max/avg with no arguments; declaring var over an existing plain variable or built-in; the draws-nothing rule.

14. Versioning and change history

npm 3.0.0 (runtime/packaging, 2026-08 — the language is unchanged at 2.4.0 and no fixtures change; this entry decouples the npm package version from LANG_VERSION):

2.4.0 (corrective — behavior deltas are deliberate bug fixes; all previously pinned outputs regenerate identically except where noted):

2.3.0 (additive): control flow — while, switch, break/continue (§3, §5); security(tf, expr) multi-timeframe evaluation (§7c); strategy depth — strategy.config, qty_type sizing, limit=/stop= pending orders with expires=, commission/slippage, pyramiding, and the capital-space summary fields initialCapital commissions endEquity returnPct cagrPct sharpe maxDrawdownPct (§11); drawing objects line.new/label.new/box.new with the 500-object cap (§10); indicator round 2 — adx diplus diminus aroonup aroondown sar supertrend supertrend_dir cci willr correlation percentile median alma (§7); trig sin cos tan asin acos atan atan2 and the pi constant; the str.* namespace (§7b); {{placeholder}} alert messages (§10, alerts[].messages). All 2.2.0 fixtures regenerate identically apart from the version and the additive result keys.

2.2.0 (additive): price= on plotbuy/plotsell (§10); trade records gain priceSource (default "close", including strategy-generated markers) and, for custom prices, prices.

2.1.0 (additive): arrays + the array.* namespace (§7a); vwap anchor argument (§7d); tostring(array) and infopanel array handling defined. All 2.0.0 fixtures regenerate byte-identically apart from the recorded version.

v1 → v2 (2.0.0)

Any observable behavior change requires a LANG_VERSION bump plus corpus regeneration in the same change. v2 is byte-compatible with v1 output for v1-feature scripts, with these deliberate deltas:

  1. Multi-line calls are legal (v1: parse error).
  2. History offsets may vary per bar (v1: series offsets were errors).
  3. Scalar-condition ternaries evaluate both branches (v1 evaluated only the taken one — observable only via side effects in branches, which v1 corpus never exercised). Per-bar color values that change replace v1's "series color" errors.

Appendix A — non-normative implementation notes