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:
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:
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 regionThe 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:
m = macd(close)
plot m.hist { style: histogram, color: if m.hist > 0 then up else down }
plot m.macd, m.signalup 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:
def zscore(x, n = 20) = (x - sma(x, n)) / stdev(x, n)
plot zscore(close) // (price − price) ÷ level → ratioFollow 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:
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:
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.
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:
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 passesA multi-condition setup reads as one expression, because that is what it is:
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 noneThe 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:
// 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:
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 euroDividing 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:
// 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:
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:
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:
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:
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:
backdrop { fill: mix(down, up, norm(ema(close, 50) - ema(close, 200))) }A session highlight paints the backdrop only while a condition holds:
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:
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:
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 8sreplay 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:
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 eitherThe 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:
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 mixRounding 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:
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:
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:
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:
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:
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:
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
- Your first session — the guided version of the first few recipes.
- The FDK overview — the namespaces these recipes draw on.
- Kinds — why the rejected examples are rejected.
- The Canvas plane — the signal algebra behind the moving ones.
- The App plane — the full contract behind the application.
- display —
viz.*, for data that is not a price series.
The formal rules →
- Kinds — why every
[ErrDim],[ErrRepr]and the box-must-be-a-levelrefusal holds: Kinds — the dimensional type system, incl. the affine substrate and numeric representation.- Time & state — delay, warm-up
na, windows and bounded iteration, running state (scan), the@clock eliminator and the as-of rule,live, pivot confirmation and points, durations, periods.- Canvas & Transitions — the signal algebra behind the moving recipes: The Canvas plane, The Transition plane.
- The App plane — the full contract behind the application, incl. capabilities and testing an application.
- Representations — the six-hook chart-type construct: Grammar — representations and drawing tools.
- Determinism — why
decimal.roundandfmt.*read the same on every engine: the pinned-routine discipline.