◆ Flux

Operators and expression semantics

This page is the reference for the operator layer of Flux: what each operator means, which kinds it accepts, what kind it produces, and why each rule is the way it is. It covers the dimensional algebra of + - * /, the two comparison families, logic on signal, the delay x[n], the resample e @ clock, ranges, indexing and projection, UFCS method syntax, record update with with, destructuring, the ? family of sugar, the _ placeholder, and the full precedence table. How kinds are assigned to whole programs — synthesis, checking, principality — is the subject of Inference; the lattice the operators compute over is defined in Kinds.

New here? Start with Guide §5 — Kinds: types that carry meaning → for the teaching version of this algebra, then return here for the per-operator rules.

A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked ✗ on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement.

One algebra, two systems

Two distinct systems cooperate under every expression, and keeping them apart is what makes the rules below predictable:

Both systems report failure through a single error channel: any undefined rule of (B) and any incompatibility of (A) produce ⊤. A ⊤ is a hard error only in a demanding position (an operand, a plot target, an argument); a ⊤ bound to an intermediate name that nothing consumes is a warning ([WarnTop]). The policy is detailed in Inference.

Shared preamble, not drift. The two-system split and the affine substrate below are stated here as the ground the operators stand on; their canonical definition — the lattice (K, ≤, ⊔, ⊓, ⊤, ⊥), the sorts, and the group of dimension exponents — lives in Kinds. This page owns only the per-operator rules that read off that ground.

The affine substrate. The price axis is a one-dimensional affine space: price is a point on it, level is a vector (a displacement), ratio is a dimensionless scalar centred on 1. Dimension itself is an abelian group of exponents over the generators {P price, V volume, T time, I bar-index, rad angle}: * adds exponents, / subtracts them. Two consequences fall out for free:

The affine substrate on one price axis Figure — the affine substrate on one price axis: price is a point, level is a vector (a displacement), ratio is a dimensionless scalar centred on 1 — so point − point = vector, point ± vector = point, and point + point names no place on the axis.

Why this rule exists. The affine reading is not decoration; it is what lets the checker say no to close + rsi(close, 14) while accepting close + atr(14) — both are “price plus a number” to an untyped eye, but only one of them names a displacement on the price axis. Every rule below is an instance of this substrate, not a special case.

Addition and subtraction: + and -

+ and - require operands of the same dimension and then follow the affine rules:

rule reading example result
pt(d) - pt(d) → vec(d) point − point = vector close - open level
pt(d) ± vec(d) → pt(d) point ± vector = point close + atr(14) price
vec ± vec → vec vectors add atr(14) - atr(28) level
ratio ± ratio → ratio scalars add vortex(14).plus - vortex(14).minus ratio
osc ± osc → propagated interval interval arithmetic on the claim rsi(close,14) - 50 osc(-50,50)
lit ± x → x a literal adopts the dimension close + 10 price
dims ≠ → ⊤ no affine meaning close + rsi(close,14) ✗ [ErrDim]
FLUX
spread   = close - open                  // price − price : level
band     = close + 2 * atr(14)           // price + level : price
centered = rsi(close, 14) - 50           // osc(0,100) − lit : osc(−50,50)
close + rsi(close, 14)                   // ✗ [ErrDim] — point + dimensionless: no affine meaning
obv() + close                            // ✗ [ErrDim] — volume and price do not add

The osc line deserves a note: the bounds of an osc(lo,hi) are a presentation claim, not a runtime invariant (only clamp makes a bound real), and ± propagates the claim by interval arithmetic. Subtracting the midline from an oscillator therefore re-centres its pane: rsi - 50 is drawn in a fixed [-50,50] pane with a midline at 0.

There is no arithmetic on signal — combine signals with and/or/not (see Logic), count them with count(sig, n).

The one categorical overload: string concatenation

The only operator line outside the dimensional table is concatenation: string + string → string. - * / and every mixed pair involving string remain outside arithmetic and produce [ErrDim].

FLUX
label = "px " + fmt.price(close) + " @ " + fmt.time(time)   // string
"holdings: " + volume                                       // ✗ [ErrDim] — format it: fmt.num(volume)

Interpolation ("px {fmt.price(close)}") desugars to the same concatenation pipeline (fmt.cat), so the two spellings are equivalent; chains of + fuse into a single bounded write at compile time. See text for the string kind’s guarantees.

Affine combinations: the Σλ rule

A weighted sum of points is meaningful exactly when its coefficients say so. On an expression normalised to Σ λᵢ · ptᵢ (coefficients are constant-folded literals, so x * 0.5 and x / 2 are the same λ, and (2*high + low + close) / 4 normalises fine), the checker applies [Affine]:

Two refinements make this rule cover real indicator algebra:

The common OHLC barycentres are pre-typed sources (hl2, hlc3, ohlc4 : price), so the 90 % case never even exercises the rule.

Asset tags gate the combination. All the points in one affine combination must carry the same asset tag, component by component, exactly as ± demands (see below):

FLUX
btcUsd = series("BTC-USD").close         // price[BTC,USD]
btcEur = series("BTC-EUR").close         // price[BTC,EUR]
mid    = (btcUsd + btcEur) / 2           // ✗ [ErrDim] — quotes differ; convert one leg first

Why this rule exists. Without the tag clause, (btcUsd + btcEur) / 2 would normalise to Σλ = 1 and type-check as a valid price, silently averaging two currencies. The affine rule must not be a back door around the mixing ban.

Multiplication and division: * and /

* and / are total on dimensions: the result’s exponent vector is the sum (respectively difference) of the operands’. The named kinds are the exponent vectors you meet most often; any other combination is still a valid, plottable composed dimension labelled by its exponents (eom : P²·V⁻¹ gets an auto pane — no warning, because a precise composed dimension is not an erased one).

rule example result
D × ratio → D close * (volume / sma(volume, 20)) price
lit × κ → κ 2 * stdev(close, 20) level
D × osc(0,1) → D close * bbPctB(close, 20) price
price × volume → pv close * volume pv (money flow)
price × price → P² close * close P², labelled pane
price ÷ price → ratio close / close[1] ratio
level ÷ level → ratio atr(14) / atr(28) ratio
level ÷ price → ratio atr(14) / close ratio
D ÷ ratio → D close / historicalVolatility(close, 20) price
pv ÷ volume → price cum(close * volume) / cum(volume) price (vwap by hand)
pv ÷ price → volume group law volume
level ÷ barspan → slope change(close, 20) / barssince(sig) slope = P·I⁻¹
FLUX
rel   = close / close[1]                     // ratio — centred on 1, own pane
flow  = close * volume                       // pv — money flow, own pane
vwap0 = cum(close * volume) / cum(volume)    // price — back on the chart

Notes on the corners:

Asset and currency tags under the operators

Kinds of price dimension carry an asset tag (B, Q[, @v]) — base, quote, optional venue; volume carries the base alone, pv the quote alone. The tag’s structure and its place in the lattice are defined in Kinds; this section specifies only how the operators act on it. They treat the tag with two different regimes, and the split is deliberate:

FLUX
btcUsd = series("BTC-USD").close      // price[BTC,USD] — a quote-tagged close
d = btcUsd - btcUsd[1]     // level[BTC,USD] — tag preserved through ±
btcUsd + ethUsd            // ✗ [ErrDim] — bases differ: adding two assets
btcUsd + btcEur            // ✗ [ErrDim] — quotes differ: adding two currencies
pnlUsd + pnlEur            // ✗ [ErrDim] — pv[USD] + pv[EUR]: convert first

Division derives exchange rates, and multiplication consumes them — the fx role rule:

FLUX
btcUsd = series("BTC-USD").close      // price[BTC,USD]
btcEur = series("BTC-EUR").close      // price[BTC,EUR]
ethUsd = series("ETH-USD").close      // price[ETH,USD]
fxUE  = btcUsd / btcEur    // fx[USD/EUR] — same base, quotes differ, concrete keys
cross = btcUsd / ethUsd    // ratio       — cross-base: relative strength, tag dropped
usd   = btcEur * fxUE      // price[BTC,USD] — shared quote cancels: (EUR/BTC)·(USD/EUR)
usd2  = btcEur / (1 / fxUE)          // ÷ fx[EUR/USD] converts the same way

// triangular chaining — both operands are DERIVED; no primitive conjures a rate
btcGbp = series("BTC-GBP").close      // price[BTC,GBP]
gbpEur = btcGbp / btcEur              // fx[GBP/EUR]
usdGbp = fxUE / gbpEur                // fx[USD/EUR] ÷ fx[GBP/EUR] → fx[USD/GBP]

The complete edge list, in one place — the price ÷ price dispatch is a 2×2 on (base equal?, quote equal?):

There is no fxRate(a, b) primitive: an fx value is derived by ÷ or arrives as a series whose producer declares itself an fx feed. The full model — venue metadata, toSource, mixed-currency series — is specified in asset-currency.

Comparisons: two families, two scopes

A single “the join is defined” test would be the wrong gate: κ ⊔ κ = κ makes every self-join admissible, including kinds with no order and no useful equality. Flux instead scopes each comparison family to the sorts where its meaning is real. Both families return signal.

Order: < > <= >= cross_up cross_down

Order comparisons are restricted to the scalar sort with a real total order, and the two sides must be dimensionally compatible (κₐ ⊔ κᵦ ∉ {quantity, ⊤}):

FLUX
breakout = close > 30000                       // price ⊔ lit = price → signal
overbought = rsi(close, 14) > 70               // osc vs lit → signal
golden = ema(close, 50) cross_up ema(close, 200)  // signal — true on the crossing bar
close > rsi(close, 14)      // ✗ [ErrDim] — price ⊔ osc = quantity: incomparable magnitudes
tf("1d") < tf("4h")         // ✗ [ErrDim] — a clock has no order to compare on
superTrend(10, 3).dir > 0   // ✗ [ErrDim] — dir is categorical: write dir == 1

cross_up / cross_down are infix comparison operators (never prefix calls): a cross_up b is true exactly on the bar where a moves from below-or-equal to above b. They sit at the comparison tier and are non-associative like the rest of it.

Asset tags gate order too: operands must carry identical tags component-by-component — price[BTC,USD] < price[BTC,EUR] is [ErrDim] even though the join of the two sides would widen to a valid price. The fx pair annotation is exempt from the gate (it lives on ratio, outside the asset axis): fxUE < fxGJ → signal compares two rates as plain ratios.

Equality: == and !=

Equality reaches further than order — onto every sort whose equality is decidable and terminating:

FLUX
st = superTrend(10, 3)
mark st.dir == 1                        // dir compares by equality → signal
bb   = bollinger(close, 20)
same = window(close, 4) == window(close, 4)   // deep, na-aware equality on a vec → signal
tf("1d") == tf("1d")                    // ✗ [ErrArg] — clock is consumed, not compared

Comparisons and na

Any comparison touching na yields na — never true, never false: na == x, na < x, even na == na are all na. Absence is tested explicitly:

FLUX
have = is_some(rsi(close, 14))     // signal — presence (not is_na(x))
gap  = is_na(close[1])             // signal — absence (first bar)

Why this rule exists. During warm-up an indicator is na for its first bars. If na > 70 silently evaluated to false, every threshold rule would fire — or refuse to fire — on phantom data, and the bug would be invisible by construction. Forcing na through comparisons makes absence propagate to the signal, where is_na / is_some handle it deliberately. The same discipline runs through the whole language: match must cover na, and window reducers propagate it.

Logic: and, or, not

The logical operators are defined on signal and only on signal (signal × signal → signal); not is a prefix operator one tier tighter than and.

FLUX
setup = close > ema(close, 50) and rsi(close, 14) < 30
flat  = not in_session("09:30-16:00 America/New_York")
close and volume        // ✗ [ErrArg] — `and` demands signals; neither operand is one

There is no short-circuit effect to speak of — expressions are pure — so and/or are plain boolean algebra on the {0,1} carrier, na-aware like everything else.

Delay: x[n]

x[n] reads the value of the stream x from n bars ago. The index must be a constant-folded natural literal:

FLUX
mom   = close - close[10]          // level — momentum as a displacement
prevH = macd(close).hist[1]        // projection, then delay — postfix chain
close[-1]                          // ✗ [ErrCausal] — the future is not addressable
close[input(5)]                    // ✗ [ErrTotal] — delay must be a compile-time constant

The same bracket syntax on a vec receiver is not a delay but an element read ([Index]): slots[h.slot] takes a runtime ordinal index, and an out-of-bounds read yields na rather than an error — the declared capacity already bounds the cost. The two roles are distinguished by the receiver’s kind, never by guesswork; see Indexing and projection.

Resample: e @ clock

@ is the eliminator of the clock kind: e @ c re-times the stream e onto the clock c and preserves e’s kind ([At]). Clocks are first-class values — composable, assignable, acceptable as input — and @ is how they are consumed:

FLUX
calm   = atr(14) < atr(50)                 // signal — a quiet-volatility regime
daily  = ema(close @ "1d", 50)             // MTF: a daily EMA under any chart timeframe
bricks = close @ renko(atrBox(14))         // representation change = clock change
c      = if calm then tf("1d") else tf("4h")
trend  = ema(close @ c, 20)                // the clock itself was computed

Clocks generalise “timeframe” — the full treatment (warm-up, alignment, live()) is in time-and-state.

Ranges: a..b

.. builds a range and exists only in range positions — it is not an expression operator, cannot be chained (non-associative), and never collides with . projection or number literals (2..200 lexes as 2 .. 200).

FLUX
bb = bollinger(close, 20)
fill bb.upper..bb.lower            // band between two price streams
len = input(14, 2..200)            // bounded parameter range

The two ends of a fill must live on the same ordered scalar axis — fill close..rsi(close, 14) is [ErrDim] (see Inference for the admissibility rules).

Indexing and projection

Projection e.f reads a field of a record ([Proj]). A missing field or a non-record receiver is [ErrField] — with a nearest-name quick-fix:

FLUX
bb = bollinger(close, 20)          // record{upper, middle, lower : price}
plot bb.upper                      // price
bb.uper                            // ✗ [ErrField] — no such field; did you mean upper?

Indexing v[i] on a vec<κ>[n] receiver reads an element ([Index]). The index may be a runtime ordinal; the declared capacity n keeps the operation total, and out-of-bounds reads yield na — never [ErrTotal], never a trap. This is the substrate of the slotmap idiom (bounded collections with stable handles and na tombstones).

A dotted name can also be a qualified name rather than a projection — T.Ctor selects a constructor of the declared variant T, and mod.f names an entry of an imported module. Both resolve during name resolution, before [Proj] is ever considered. Which brings us to the dot’s third meaning:

UFCS: close.ema(20).rsi(14)

Uniform Function Call Syntax: recv.f(args) is exactly f(recv, args) — the receiver becomes the first argument. It is a semantic desugaring on the already-parsed tree, not a grammar form, and it is what makes left-to-right pipelines read the way the data flows:

FLUX
smooth = close.ema(20).rsi(14)                      // ≡ rsi(ema(close, 20), 14)
top5   = vec.topK(window(close, 100), (x) -> x, 5)  // a namespace call — the dot means something else here

The dot has three meanings, decided at compilation with one token of lookahead:

  1. recv.f without ( — a record field (bb.upper), rule [Proj];
  2. recv.f( where f resolves to a function — a UFCS call, desugared to f(recv, …);
  3. mod.f / T.Ctor where the left side names a module or a declared variant — a qualified name, resolved before either of the above.

Tie-break: if recv has a field f and f names a function, recv.f(…) is the UFCS call — a field is never callable, so the other reading could only be an error.

The trap is reading (2) where only (3) applies. UFCS reaches the functions in scope — the prelude — and topK is not one of them: it lives in vec.*. So w.topK(…) is not a UFCS call but a field projection on a vector, which is [ErrField]. Write vec.topK(w, …). The rule of thumb is the one the completion menu already enforces: if the editor does not offer it after the dot, it is not there.

Why this rule exists. UFCS adds zero grammar and zero semantics — the checker verifies recv against the function’s first parameter exactly as if you had written the direct call — but it buys discoverability: after close., the editor can propose precisely the functions whose first parameter accepts a price, filtered by kind. One mechanism, readable pipelines, kind-aware completion.

Named arguments compose with it: recv.f(x, mode: fast) binds mode by parameter name and checks it like a positional (see Inference).

Record update: e with { … }

with produces a record identical to e except for the listed fields. It is shape-preserving ([With]):

FLUX
variant Phase { ask | suspense | revealed }
m  = { score: 0, phase: ask }
m2 = m with { score: m.score + 1, phase: revealed }
m with { scrore: 0 }        // ✗ [ErrField] — typo caught, nothing silently added

with is a postfix suffix (same tier as call and projection), so it chains: m with { a: 1 } with { b: 2 }.

Why this rule exists. Functional update without with means rebuilding the whole record by hand — and a forgotten field is silent state loss. Shape preservation turns that class of bug into [ErrField] at compile time.

Destructuring: let {a, b} = e

A record pattern on the left of a binding extracts fields by name. It is irrefutable — it can never fail at runtime, so it is allowed exactly where no branching is possible:

FLUX
w = let { upper, lower } = bollinger(close, 20) in upper - lower   // level — `let … in` is an EXPRESSION
{ macd, hist } = macd(close)                                       // a program-level bind destructures too

The ? family: ??, ?., ternary

Three pieces of pure sugar, all desugared to sealed constructs — no new kinds, no new semantics:

sugar desugars to notes
x ?? d nz(x, d) kind = x ⊔ d; fills na with a default
e?.f if is_na(e) then na else e.f safe navigation; chainable, short-circuits at the first na
c ? a : b if c then a else b same tree; c must be a signal
FLUX
m     = macd(close)                       // record{macd, signal, hist}
len   = close[1] ?? close                 // price — first bar handled
hist  = m?.hist ?? 0                      // safe-nav then default
side  = close > open ? 1 : -1             // ternary, right-associative

Because ?? is nz, it follows the join rules: branches of different dimensions widen toward quantity (with a warning), and mixing representation tags (f64 vs decimal) is [ErrRepr] — the sugar cannot do what the desugared form would not.

The _ placeholder

At the argument site of a higher-order kernel, a single free _ denotes the one parameter of an implicit lambda:

FLUX
scaled = window(close, 20).map(_ * 1.1)      // ≡ .map((x) -> x * 1.1)
present = vec.where(window(close, 20), (x) -> is_some(x))  // a namespaced HOF — write the lambda
window(close, 20).fold(0, _ + _)   // ✗ — two-parameter position: write (acc, x) -> acc + x

The placeholder is confined to a prelude kernel reached by UFCS, where the receiver fixes the one parameter; a namespaced higher-order call such as vec.where(…) takes an explicit lambda, because _ has no binder to attach to there.

The rule is strictly mono-argument: exactly one _, in a position expecting a one-parameter function. Two or more _, or a two-parameter site like fold, are rejected with a request for an explicit lambda — the sugar never guesses which _ is which. The pattern _ of match arms is a different, position-distinct symbol; the two never collide.

Precedence and associativity

From loosest to tightest. The normative stratified grammar — including why this table needs no precedence annotations at all — is in grammar.

tier operators associativity notes
1 -> right one arrow token; its role (lambda, tween pair, on, match arm, for) is decided by its head position — see grammar
2 c ? a : b · if…then…else · let…in right else is mandatory — no dangling-if
3 ?? right null-coalescing
4 or left
5 and left
6 not prefix
7 < > <= >= == != cross_up cross_down non-associative a < b < c is a parse error — write a < b and b < c
8 + - left
9 * / % left
10 unary - prefix tighter than *: -a * b = (-a) * b
11 postfix: f(…) · [n] · @c · .m · ?.m · with {…} left one tier, applied in lexical order: f(x)[1]@"1d".m = (((f(x))[1]) @ "1d").m
12 primary — literals, names, (…), […] list, {…} record/block, match, scene

Outside the cascade:

Each tier refers only to the next tighter one — no cross-recursion — which is what makes the whole cascade unambiguous by construction rather than by side condition.

See also