Skip to content

Xlang — the formula language ​

Xlang is Pangura's small scripting language for time series. It lives inside one Canvas node (Script — the node hosting both lanes), where it turns the outputs of the nodes wired into it — price series, factor readings, model outputs — into your own custom factor, measure, or signal.

It is deliberately small: an afternoon is enough to learn all of it. This page is the complete reference — there is nothing about the language that is not on this page.

Why a bounded language ​

Xlang is total by construction: every script terminates, every run is deterministic, and the resources a script can consume are capped. That is not a limitation bolted on afterwards — it is the design. Because the language cannot express unbounded loops, recursion, I/O, randomness, or clock reads, every Xlang script is auditable by reading it, reproducible by re-running it, and safe to schedule unattended in nightly monitoring.

Three things Xlang is deliberately not:

  • Not a general-purpose language. The power stays in the platform's operators and graph topology; the language stays small enough to learn in an afternoon and small enough to audit at a glance.
  • Not a notebook. Arbitrary user code destroys the audit boundary — nobody can attest a black box. In Xlang, the dangerous things are not forbidden by policy; they are unwritable by grammar.
  • Not raw JSON configuration. The parsed syntax tree is the storage format; the language is the human surface.

Quick start ​

Wire two Price Factor nodes (say copper and gold) into a Script node (Xlang lane), and paste:

input copper: series    # ← first wired upstream
input gold: series      # ← second wired upstream

let ratio = copper / gold
let z = zscore(ratio, 60)

# path-dependent state: count consecutive stretched days
let regime_days = 0
for i in 1..len(z) {
  if z[i] > 2 { regime_days = regime_days + 1 } else { regime_days = 0 }
}

output value  = z[len(z)-1]
output series = z
output signal = if regime_days > 5 { "risk_off" } else { "neutral" }

Press ▶ Dry run on the node card to evaluate immediately against the last graph results, or run the graph to execute it in place. The node card shows the value, the signal, the derived series as a sparkline, and — expanded on demand — the formula itself with its SHA-256 fingerprint.

Execution model: whole series, explicit loops ​

A script runs once per evaluation and sees each input as a complete, aligned series. Path-dependent logic (trailing stops, regime counters, custom drawdowns) is written as an explicit for loop over the series — there is no hidden per-bar re-execution. What you read is what runs.

Types ​

TypeNotes
number64-bit float. NaN is a legal value and propagates (see NaN discipline).
booltrue / false. Conditions must be boolean — if price { … } is an error; write if price > 0 { … }.
seriesAn array of numbers, bounded at 20,000 points. Created by inputs, slices, and builtins.
stringLiteral labels only (for output signal); no string operations.

Statements ​

input NAME: series      # declare a series input (binds to a wired upstream)
input NAME: number      # declare a scalar input

let NAME = expr         # declare a variable — required before any assignment
NAME = expr             # reassign (only after let; assigning an undeclared
                        #   name is a parse error — this catches typos early)

if cond { … } else { … }        # statement form; else optional
for i in a..b { … }             # range loop: a inclusive, b EXCLUSIVE; a >= b runs zero times
for v in s { … }                # iterate the points of series s

output value  = expr    # scalar reading (number) — what Monitor probes watch
output series = expr    # derived series (plotted on the node card)
output signal = expr    # string label, e.g. "risk_off"

A script must declare at least one output; each output slot may appear at most once. There is no while, no recursion, no user-defined functions (v1), no import — these are rejected at parse time with an explanation, not silently ignored.

Expressions ​

Precedence, loosest to tightest: or → and → comparisons (> >= < <= == !=) → + - → * / → unary (-x, not x) → postfix (call f(x), index s[i], slice s[a:b]).

  • Arithmetic between series is pointwise and requires equal lengths; series-and-number mixes apply the number to every point.
  • If-expression: if c { a } else { b } — the else branch is required in expression form.
  • Indexing is zero-based; the last point is s[len(s)-1]. Negative indexes are invalid (the error message will remind you of the len(s)-1 idiom).
  • Slicing s[a:b] is end-exclusive and clamps out-of-range bounds.
  • Boolean operators are the words and / or / not — &&, ||, ! are rejected with a hint.

Builtins — the complete list ​

Twenty-two functions. Nothing else exists; calling anything not on this list is an error naming the valid alternatives.

FunctionReturnsSemantics
len(s)numberPoint count; NaN points are counted.
sum(s)numberSum. Any NaN → NaN; empty → NaN.
mean(s)numberArithmetic mean. Any NaN → NaN; empty → NaN.
std(s)numberSample standard deviation (ddof = 1). len < 2 → NaN; any NaN → NaN.
min(s) / max(s)numberExtremes. Any NaN → NaN; empty → NaN.
abs(x) / log(x) / sqrt(x)same typeNumber or series, pointwise. log(x ≤ 0) → NaN, sqrt(x < 0) → NaN — propagate, never throw.
lag(s, k)seriesout[i] = s[i−k]; first k points NaN; k = 0 copies.
diff(s, k=1)seriesout[i] = s[i] − s[i−k]; first k points NaN.
pct_change(s, k=1)seriesout[i] = s[i]/s[i−k] − 1; first k points NaN.
sma(s, w)seriesRolling mean; first w−1 points NaN; NaN in window → NaN at that point.
ema(s, w)seriesα = 2/(w+1); seed = SMA of the first w points (classic TA convention); then out[i] = α·s[i] + (1−α)·out[i−1]; first w−1 points NaN.
zscore(s, w)series(s[i] − mean_w) / std_w with sample std; first w−1 NaN; std_w = 0 or NaN in window → NaN.
corr(a, b)numberFull-series Pearson correlation (sample). Lengths must match; len < 2, zero variance, or NaN → NaN.
rank(s, w)seriesRolling percentile rank = count(window ≤ s[i]) / w, in (0, 1]; first w−1 NaN.
percentile(s, p)numberp in [0, 100], linear interpolation (numpy linear); NaN → NaN.
crossover(a, b)series0/1 series: a crosses above b (b may be a series or a number threshold). out[0] = NaN; NaN at any deciding point → NaN.
crossunder(a, b)seriesMirror of crossover: a crosses below b.
highest(s, w) / lowest(s, w)seriesRolling max / min; first w−1 points NaN.

EMA seeding convention

Xlang's ema uses the classic technical-analysis convention: the first defined point is the SMA of the first w points. Pandas' ewm(span=w, adjust=False) instead seeds with the first value, so the two agree only after the seed influence decays (they converge to within 10⁻⁶ after roughly seven window-lengths). If you compare against pandas, compare tails.

NaN discipline ​

NaN is data, not an error. It enters through short histories and rolling warmups, and it propagates strictly: any arithmetic or aggregate that touches NaN yields NaN, and rolling builtins emit NaN wherever their window is incomplete or contaminated. Series always keep their input length — warmups are NaN-filled, never trimmed. If your latest reading is NaN, the honest interpretation is "not enough clean history", and that is exactly what nightly monitoring will record (nodata, never yesterday's value).

Inputs: binding and alignment ​

Bindings live on the wire (plan A, 2026-08). Click an incoming edge of the Script node and set which input it feeds (plus an optional field path). The binding is a property of the edge itself: renaming the upstream ticker keeps it, deleting the wire removes it, and the result card labels such inputs (edge). Precedence: edge binding > input map > auto-bind — the input map stays as an advanced fallback. Both lanes consume edge bindings identically; on the Python lane an edge binding acts as the de-facto input declaration, and unbound wires keep their u0, u1, … names with original wire indexes.

Series inputs bind to wired upstream nodes automatically, in declaration order: the first input …: series takes the first wired upstream that exposes a usable series, the second takes the next, and so on. Number inputs search the wired upstreams for a numeric field with the input's own name. Name inputs for the reader — copper, gold, spread — position does the wiring.

When automatic binding is not what you want, the node's Input map field takes one line per input. The Input map is its own small field below the script box — map lines never go inside the script (the script starts with input declarations):

# input_name = where it comes from
copper = upstream[0].price_series   # first wired node (0-based, same rule as s[0])
gold   = upstream[1].price_series   # explicit field on the second wired node
spread = basis.mean_bps             # a path searched across all upstreams
k      = 2.5                        # literal number (number inputs only)

upstream[N] counts wired nodes in order, 0-based — the same rule as series indexing (s[0] is the first point), and exactly what the result card's binding lines show. A bare 0.price_series is rejected with a hint (it reads like a decimal).

Multiple series of different lengths are tail-aligned to the shortest — the most recent points are matched, older overhang is trimmed, and the node result records exactly what was trimmed. The node card surfaces this ("tail-trimmed to N pts") so alignment is never silent.

Resource caps and failure ​

CapLimit
Execution steps5,000,000 (series operations charge per element)
Series length20,000 points
Total allocated elements200,000
Wall clock2 seconds

Exceeding a cap stops the run with a plain-language error naming the cap. A failed script never produces partial numbers: on the Canvas the node card shows the error with line and column plus the script; in nightly monitoring the reading records nodata with the reason — never a stale value.

Errors speak trader ​

Error messages carry line and column and say what to do:

line 1, col 1: 'while' is not in this language — loops are bounded:
  write 'for i in a..b { … }' or 'for v in series { … }'

line 2, col 1: 'momentom' is not declared — write 'let momentom = …'
  first (this catches typos early)

Provenance ​

Every execution result carries the language version, the script source, and the script's SHA-256 fingerprint. The evaluator that runs your dry-run in the browser and the one that runs nightly monitoring on the server are byte-identical twins, locked by checksum tests, and the evaluator itself is pinned by a golden test suite (30 specification cases against an independent NumPy reference, plus cross-checks against the platform's own indicator operators). Same script, same inputs, same answer — that is a testable promise, not a slogan.

Qibo, the language desk ​

Ask Qibo (in the node's configuration dialog) to draft a script from one sentence — "flag when the copper/gold ratio is two sigmas rich" — or to explain an existing script in plain words. Drafts are verified server-side before you ever see them: they must parse and complete a dry run on synthetic inputs. A draft that fails verification is never shown; you adopt a draft explicitly before it touches your node.

Three worked examples ​

1. Cross-asset ratio regime (the quick-start script above). Ratio → rolling z-score → explicit loop counting consecutive stretched days → value + series + signal outputs.

2. Maximum drawdown, path-dependent:

input s: series
let peak = s[0]
let max_dd = 0
for v in s {
  if v > peak { peak = v }
  let dd = 0
  dd = v / peak - 1
  if dd < max_dd { max_dd = dd }
}
output value = max_dd

A running-peak loop no builtin provides — this is exactly the kind of logic the bounded for exists for.

3. Threshold gate with a scalar input:

input s: series
input k: number
output signal = if mean(s) > k { "above" } else { "below" }

k can be bound to a literal in the input map (k = 2.5) or to a numeric field of any wired node — so the same script serves as a reusable, parameterized gate.

All three examples are locked in the evaluator's golden suite — the documentation you just read is tested.

The Python lane (2026-08) ​

The same Script node offers a second lane: Python 3.13, sandboxed. Pick the language at the top of the node config.

Xlang lanePython lane
Contractinput declarations → logic → outputdef run(inputs) returning {"value"/"series"/"signal": …}; inputs is a dict of named pandas.Series
Vocabulary22 builtinsnumpy 2.2.6 · pandas 3.0.1 · scipy 1.17.1 · statsmodels 0.14.6 · scikit-learn 1.8.0 · openpyxl 3.1.5 (exact-pinned whitelist, mirroring the main service — the sandbox image contains these six packages and nothing else; BLAS pinned single-thread for determinism)
Where it runsIn-process evaluator (browser dry-run = nightly batch, byte-identical twins)One-shot server container: no network, read-only filesystem, unprivileged user, CPU/memory/process caps
DeterminismGuaranteed by construction — the language cannot express randomness or IOVerified by probe: dry-run executes twice; matching output hashes grant the double-run consistent badge. A later run with the same code and inputs but a different output automatically revokes the badge and raises an alert
ProvenanceScript SHA-256 + evaluator versionScript SHA-256 + sandbox image digest + input/output hashes + interpreter and package versions measured inside the container
Signing & evidenceDefault lane for the signing chainReadings carry the python-sandbox lane label; equal hash-chain provenance, honestly weaker human-attestability

Bindings are shared: the input map works identically on both lanes, and with no map the Python lane auto-binds each wired upstream's primary series as u0, u1, … (indexes match upstream[N]). Series of different lengths are tail-aligned exactly as on the Xlang lane.

The sandbox harness — series injection, native-type conversion, the canonical output-hash rule, and the double-run probe — is locked by its own golden (python_sandbox_harness, Tier 2), against a pure-stdlib reference.

Verifiable intelligence for the decisions that demand scrutiny.