◆ Flux

Cookbook — recipes that run

You have met every construct; this chapter is the payoff. It is a shelf of complete programs — each short enough to read in one breath. The analysis recipes paste into a running chart and work today; the scene and application recipes are complete programs in the sealed design — surfaces you can read and reason about, shown as designed code rather than run. Nothing here is a fragment: every fence is a whole program.

They are ordered the way you actually meet them — the first line you will ever write near the top, whole applications and custom chart types near the bottom. Read straight through and it doubles as a tour of the language from one clean line to a self-contained app. One convention runs throughout: a line marked // ✗ is a rejected example, kept on purpose. Knowing what Flux refuses, and why, teaches more than one more thing that happens to work.

An indicator, and everything it infers

The shortest useful program in the language is one indicator, and it comes with more than it says:

FLUX
plot rsi(close, input(14))

Own pane, fixed 0–100 scale, midline, 30/70 guides, a parameter control. All from the kind. You wrote the intent; the presentation followed because rsi returns a bounded oscillator and a bounded oscillator knows how it wants to be shown.

A band, and a fill

Bollinger bands are three related series and the region between two of them. You plot the series, then name the region by its two bounds:

FLUX
bb = bollinger(close, 20, 2)
plot bb.upper, bb.middle, bb.lower
fill bb.upper..bb.lower

// ✗ fill bb.upper..rsi(close, 14)   — [ErrDim]: a price and an oscillator do not bound a region

The rejected fill is the lesson: a region needs two bounds that share a scale. A price and an oscillator live in different panes with different units, so upper..rsi is not a band — it is a category error, and the kind system says so before you ever see it drawn wrong.

A histogram with a sign-driven colour

MACD’s histogram wants two colours, one for each side of zero. The colour is an expression, so you write the rule inline:

FLUX
m = macd(close)
plot m.hist { style: histogram, color: if m.hist > 0 then up else down }
plot m.macd, m.signal

up and down are semantic tokens, not literal hexes — they resolve against the active theme, so the histogram stays legible whether the reader runs light or dark.

Your own function

def gives you an indicator of your own, and the dimensional system checks its physics for free. A z-score subtracts a mean from a price and divides by a level:

FLUX
def zscore(x, n = 20) = (x - sma(x, n)) / stdev(x, n)

plot zscore(close)            // (price − price) ÷ level → ratio

Follow the units in the comment: a price minus a price is a displacement, divided by a level it becomes a dimensionless ratio. You never annotated a single type — the result kind was derived from the arithmetic, and it is a ratio because the algebra could not make it anything else.

A ribbon

A moving-average ribbon is the same plot done eight times, its colour swept across a gradient. repeat unrolls at compile time, so i is a constant on every pass:

FLUX
repeat 8 as i {
  plot ema(close, 10 + i * 10) { color: mix(down, up, i / 7) }
}

A divergence

Divergence is where the analytics get interesting, because the recipe has to reach back in time to a prior event. A bearish divergence is a higher high in price standing against a lower high in RSI:

FLUX
def bearDiv(n) =
  let ph  = pivot_high(close, n, n) in           // (source, left, right) — confirms n bars later
  let px  = valuewhen(ph, close[n]) in           // this pivot's price
  let osc = valuewhen(ph, rsi(close, 14)[n]) in  // and its RSI
  px > valuewhen(ph, px[1]) and osc < valuewhen(ph, osc[1])

mark bearDiv(5) "bearish divergence"

A higher high in price against a lower high in RSI — which means the recipe has to reach one pivot back, and that is the part worth stealing. valuewhen(ph, px[1]): at the bar where a pivot confirms, px has just taken this pivot’s value, so px[1] still holds the previous one — sampling it exactly there, and holding it, is how you compare two successive pivots.

How valuewhen samples a pivot's price at the confirming bar while px[1] still carries the previous pivot Figure — at each confirming bar valuewhen latches the new pivot into px, so px[1] reaches back to the pivot before it.

valuewhen has no occurrence argument, and ph[1] is not a substitute for one: it delays the signal by a bar, not by a pivot.

Pivots are confirmed pivots — they carry a lag, which is why the price of the pivot is close[n] and not close, and they are final once emitted. A pivot that mutated until confirmation would be a repaint, and there is no way to write one.

Signals, marks and alerts

A signal is just a boolean stream. Once you have one you can mark it, alert on it, and even assert against it — three different consumers of the same true/false line:

FLUX
cross = close cross_up ema(close, 50)

mark  cross "crossed at {fmt.price(close)}"
alert cross "EMA-50 crossed up"
assert rsi(close, 14) <= 100 "rsi is bounded"     // a self-check; `na` during warm-up passes

A multi-condition setup reads as one expression, because that is what it is:

FLUX
setup = close > ema(close, 200)
    and rsi(close, 14) < 35
    and volume > sma(volume, 20) * 1.5
    and in_session("09:30-16:00 America/New_York")

mark setup { shape: triangle }

// ✗ mark setup { shape: triangle, color: up }   — [ErrArg]: `color:` is a `plot` channel; a mark has none

The rejected line marks the boundary between two constructs that look alike: a plot carries a color: channel, a mark does not. A mark is a labelled event, not a series, and the argument list is where that distinction is enforced.

More than one clock

You can reach a coarser timeframe from any chart with @, and read it three ways at once — as a colour, as a plotted series, and as a miniature:

FLUX
// paint the bars by the daily trend, on whatever chart you are looking at
color bars: if close > ema(close, 200) @ tf("1d") then up else down

// a higher-timeframe oscillator, shown here
plot rsi(close, 14) @ tf("4h")

// a miniature of the daily series, in the corner
sparkline close @ tf("1d") { at: (screen.right, screen.top) }

Each of these reads the last closed unit of the coarser clock. The value a bar showed yesterday is the value it shows today — nothing about a higher timeframe repaints as the finer bars fill in, because you are always reading a unit that has already closed.

Cross-series

A second instrument is a value, so relative strength is arithmetic between two series:

FLUX
btc = series("BTC-USD")
eth = series("ETH-USD")

plot btc.close / eth.close                                       // ratio — relative strength
plot stat.correl(returns(btc.close), returns(eth.close), 30)     // osc(-1,1)

// ✗ plot btc.close + eth.close     — [ErrDim]: different bases
// ✗ plot btc.close + series("BTC-EUR").close   — [ErrDim]: a dollar is not a euro

Dividing two prices is fine — the ratio is dimensionless. Adding them is not, and the two rejected lines say why in the same breath: a BTC price and an ETH price have different bases, and a dollar price and a euro price carry different currency tags. The asset tag rides along in the kind, so the compiler catches a nonsense sum that a bare number would have let through.

State

Some values depend on their own past. scan threads a running accumulator through the stream — here a trailing stop that only ever ratchets up:

FLUX
// a stop that ratchets and never loosens
def trail(mult) =
  let stop = close - mult * atr(14) in
  scan(stop, (prev) -> math.max(prev, stop))

plot trail(3)

When the accumulator is a whole mode, model it as a variant and step it with match. This is a Wyckoff-style trend flip — up until price breaks a band below the reference, down until it breaks one above:

FLUX
variant Trend { Up | Down }

def step(p, n) = match p.dir {
  Up   -> if close < p.ref - atr(n) then { dir: Trend.Down, ref: close } else p
  Down -> if close > p.ref + atr(n) then { dir: Trend.Up,   ref: close } else p
}

def flip(n) = scan({ dir: Trend.Up, ref: close }, (p) -> step(p, n))

color bars: match flip(14).dir { Up -> up ; Down -> down }

Note the def step pulled out of the scan(…) call: a match written inside a call’s parentheses needs explicit separators between its arms, and lifting it out is the readable fix.

Canvas

The Canvas plane is where a chart stops being a plot and starts moving. Its graphics and text both render on WebGPU — sidestepping DOM layout cost, text as SDF glyphs crisp at any zoom; you write the motion as a signal and the engine draws it. A comet is a glowing dot that trails its own path:

FLUX
circle { at: (bar.i, spring(close)), r: 6, glow: 16, trail: 24 }

Fireworks fire once, on an event — here the first close above a 250-bar high:

FLUX
on close cross_up highest(close, 250)[1] -> burst(40) ring { at: (bar.i, close), r: 6 -> 24, opacity: 100% -> 0%, life: 2s }

A heartbeat pulses on a schedule instead of a condition:

FLUX
on every(1 bar) -> spawn ring { at: (bar.i, close), r: 4 -> 20, opacity: 80% -> 0%, life: 900ms }

A trend aurora tints the whole backdrop by how far the fast average sits above the slow one:

FLUX
backdrop { fill: mix(down, up, norm(ema(close, 50) - ema(close, 200))) }

A session highlight paints the backdrop only while a condition holds:

FLUX
backdrop { fill: token.grid, opacity: 8% } when in_session("09:30-16:00 America/New_York")

And auto support-and-resistance draws two lines that hold the last confirmed pivots:

FLUX
group {
  line { at: (bar.i, valuewhen(pivot_high(close, 5, 5), close[5])), w: screen.w, stroke: down }
  line { at: (bar.i, valuewhen(pivot_low(close, 5, 5),  close[5])), w: screen.w, stroke: up }
}

The last confirmed swing high and the last confirmed swing low, each held until the next pivot replaces it.

Why not the last four pivots? Because a window is a window over bars, not over pivots: window(valuewhen(ph, close), 4) takes four bar-samples of a step-held series, and between two pivots that is the same level, four times over. A sparse series — N pivots, however far apart they fall — is not a window at all. It is a representation with a declared maxPivots, the same bounded pattern as Point & Figure further down this page. An unbounded list of pivots is not something you can ask for, and that is the totality rule doing its job.

Transitions

Transitions animate the view itself — a re-frame or a re-fit that the reader sees but the analysis never does. You trigger them on interface events and on signals:

FLUX
on switch(asset) -> morph chart over 500ms { ease: inOutCubic ; stagger: 0.3 ; surplus: collapse }
on click        -> focus(view, at: (bar.i, close), zoom: 2.0, over: 600ms, ease: outBack(1.2))
replay from close cross_up ema(close, 200) over 8s

replay from takes a signal, not a bar: you replay from the moment something became true, and the engine finds the bar. A replay anchored to an ordinal would mean something different on every chart it ran on.

The forming bar

The current bar is not closed yet, and Flux draws a hard line around what you may do with its live value. You may display it; you may not decide on it:

FLUX
plot ema(live(close), 20)                  // ✓ display only — updates within the forming bar
// ✗ alert ema(live(close), 20) > 100      — [ErrFirewall]: a decision may not read a forming value
// ✗ plot rsi(live(close), 14)             — [ErrFirewall]: analysis may not consume it either

The first line is flagged non-replayable in the guarantees panel — visibly, at the moment you make the trade. A forming value is honest for the eye and poison for a decision, so the firewall lets it reach a plot and stops it reaching an alert or an analysis.

Money and exactness

Prices and quantities are not floats. The glued d suffix makes a literal an exact decimal, and the arithmetic tracks the scale:

FLUX
qty    = 3d                                  // decimal(scale 0) — the glued `d` makes it exact
px     = 41.25d                              // decimal(scale 2)
gross  = qty * px                            // `×` sums the scales → decimal(scale 2)
fee    = decimal.round(gross * 0.001d, 2)    // to 2 decimals, half-even, deterministic

// ✗ plot toFloat(fee) + fee                 — [ErrRepr]: an f64 and a decimal do not mix

Rounding is not a mode you pick: decimal.round is half-even and pinned, one routine shared by the interpreter, the compiled module and the server — because a rounding that differed by engine would put two machines a cent apart, and byte-determinism would be a word.

Text

A label is a string with values interpolated into it. Interpolation calls a formatter, and the formatter is pinned:

FLUX
sym = "BTC-USD"
mark close cross_up ema(close, 50) "{sym} {fmt.price(close)} ({fmt.pct(change(close, 1) / close[1])})"

A message slot takes a string literal, never a binding that happens to hold one. The label is therefore written where it is read.

Interpolation is a formatter call, and the formatter is pinned — so the label reads the same on every engine.

Calendar

“One day” and “24 hours” are not the same thing, and the type system keeps them apart. A period is calendar-aware; a duration is a fixed machine span:

FLUX
expiry = time + time.months(3)      // period — calendar, DST-aware
cutoff = time + 86400s              // duration — exactly 24 hours, DST or not

// ✗ plot time.days(1) + 86400s      — [ErrRepr]: a calendar span and a machine span do not add

“One day” and “24 hours” are different things twice a year, and the type system knows which one you meant. A period is built from time.* and resolved against a calendar; a duration is a literal carrying an s or ms suffix, and is exactly as long as it says.

An application

At the top of the ladder is a whole application: a model, an update function, a view, and a set of subscriptions. Everything ambient arrives as a message; every effect leaves as data:

FLUX
variant Msg { Tick | Reset | Got(v: num) }

app watch {
  capabilities: [ clock, chart:read, sfx ]

  init(p)        = { n: 0, last: na }
  update(m, msg) = match msg {
                     Tick   -> { model: m with { n: m.n + 1 }, cmds: [] }
                     Reset  -> { model: m with { n: 0 },       cmds: [ PlaySfx("reset") ] }
                     Got(v) -> { model: m with { last: v },    cmds: [] }
                   }
  view(m)        = row {
                     text("ticks: {m.n}")
                     text("rsi: {fmt.num(m.last)}")
                     button("reset", Reset)
                   }
  subs(m)        = [ OnTick(1000, Tick), OnSeries("rsi", Got).throttle(200) ]
}

Everything ambient arrives as a message; every effect leaves as data. Which is why an application is tested at four grains — step (one update), trace (a fold over a literal list of messages), view (a snapshot of the view tree) and property (an invariant asserted over a generated trace) — all of them assertions over pure functions, with no mock anywhere. The first two:

FLUX
assert update({ n: 0, last: na }, Tick) == { model: { n: 1, last: na }, cmds: [] }

That is the step grain. The trace grain folds a literal list of messages through the same update — assert fold(init(p), [ Tick, Tick, Reset ]) == { n: 0, last: na } — and the literal list is the whole point: it is the mock. There is no Sub to stub, no clock to fake, no network to intercept, because none of them ever reach update. They only ever produced messages, and a message is a value you can type out by hand.

A chart type, as a script

A chart type is not a native renderer you configure — it is a representation you write, six hooks that say how price becomes columns and how those columns stay bounded. Point & Figure, in full:

FLUX
representation pnf(box, rev) {
  transform:      rebin(close, box, rev)   // re-bin price into X/O columns
  render:         column { at: (clock.index, lo), h: hi - lo, fill: if dir == 1 then up else down }
  reduce:         merge(cols)              // the column-correct decimator
  liveReduce:     last(cols)               // extend the head column, or reverse → append
  updateLastUnit: patch(cols)              // mutate the head column in place
  persistKey:     "pnf-v1"                 // a price + box-ordinal anchor
}

All six hooks are mandatory and carry a value — there is no eliding one behind a comment. A representation that could not say how it decimates, or how it extends its head unit live, would not be a chart type; it would be a chart type’s first half.

The type system forces the physics: box must be a level (a displacement), because anchor + count * box only types that way. Model the box as a price and the compiler refuses — which is the moment you learn something about Point & Figure.

Things that do not compile, and why

The last recipe is a list of programs that will never run, each rejected for a reason you can name:

FLUX
close + rsi(close, 14)        // ✗ [ErrDim]      — a point plus a dimensionless number
close[-1]                     // ✗                — there is no negative index; the future has no syntax
window(close, len)            // ✗ [ErrTotal]     — a window bound must be a constant
close @ renko(50) @ tf("1d")  // ✗                — one clock per series in v1
w.filter((x) -> x > 0)        // ✗                — a data-dependent length would break totality;
                              //                    use `vec.where(w, (x) -> x > 0)` — same length, `na` where false
match dir { 1 -> up }         // ✗                — `dir` is a scalar; discriminate it with `==`

Each of these is a design decision you can read about, not a limitation you have to work around blindly: Kinds, Time & state, The four planes.

See also

The formal rules →