◆ Flux

Signals, marks and alerts

Most of what you have written so far measures something — an RSI reading, a moving average, a spread. This chapter is about the other kind of answer: yes or no, and exactly when. When you ask whether price just crossed a moving average, you are not asking for a number, you are asking for a moment, and Flux gives that its own kind — the signal — with its own rules about how it may be shown.

A signal is a stream of truth values, one per step, and the single most useful fact about it is what the language refuses to do with it: it will never draw a signal as a line. A yes/no that slithers up and down a price axis is a lie about what it is, so the compiler shows a signal the way a signal deserves to be shown — as marks on the bars where it holds — and offers you the same object for the two other things you reach for constantly: raising an alert when it turns true, and asserting that it stays true as a check on your own work. One expression, three uses. Once you see them as one thing, setups stop being a special feature and become ordinary arithmetic of truth.

A comparison is a signal

Any comparison produces a signal. The most common one is a crossover:

FLUX
cross = close cross_up ema(close, 50)

cross is not a price and not a number — it is a signal, true on exactly the bars where the condition holds and false everywhere else. cross_up is an infix operator, not a function call: a cross_up b is true precisely on the bar where a moves from below-or-equal to above b, and its mirror cross_down catches the fall. Like every operator in the ANALYSIS plane, it reads only closed data, so the crossing it reports on a bar is the crossing that bar will always have reported — no later data can take it back.

You build richer conditions the way you build richer arithmetic, except the algebra is logic. Signals combine with and, or and not, and with nothing else — there is no arithmetic on a truth value, because adding two yes/no answers means nothing:

FLUX
cross = close cross_up ema(close, 50)
quiet = atr(14) < atr(50)
setup = cross and quiet and not is_na(rsi(close, 14))

One sharp edge worth learning once: the comparison operators are non-associative, so a < b < c is a parse error rather than a silent misreading. When you mean a band, you say it in full — a < b and b < c — and the language holds you to it. Kinds still apply underneath all of this; a comparison only types when both sides live on the same axis, which is why close < 30000 is a signal and close < rsi(close, 14) is a dimensional error. Guide §5 tells that story.

Showing it: mark

To drop a marker on every bar where a signal holds, you write mark:

FLUX
cross = close cross_up ema(close, 50)

mark cross "crossed at {fmt.price(close)}"

That is the whole idiom. mark takes a condition and an optional label; the host places a marker on each true bar and never anywhere else. You did not pick a shape, a colour or a lane — the kind decided that a signal is presented as marks, and a mark is what you get. Trying to plot the same signal, to draw it as a line, is a presentation error, not a stylistic choice you talked the compiler out of: a signal has no line to draw.

The label interpolates. "{fmt.price(close)}" splices the live close into the marker text at each bar, so the annotation carries the value that made it fire. A marker’s channels are its own small set — shape, size — and it deliberately does not have the channels a plot has:

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

Look at what setup is: a four-line condition read as a single boolean expression, because that is exactly what it is. A trend filter, an oversold reading, a volume surge and a session window, all and-ed together, produce one signal, and one signal is one mark. There is no rule engine to configure and no callback to register — a setup is arithmetic of truth, and the marker is its picture. The rejected line names the boundary crisply: color: belongs to a plotted series, a mark has no such channel, and the compiler says so by name rather than quietly ignoring it.

The same signal shown three ways: a mark on the chart, an alert leaving it, and an assert guarding it Figure — one signal, three sinks: mark draws it, alert sends it, assert checks it — the same object each time.

The message is written where it is read

A label is a string, and the message slot has a rule that looks strict until you see why it is a kindness: it takes a string literal, never a binding that merely happens to hold one.

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

Because the text lives at the sink, the label is written exactly where it is read — you never chase a variable across the file to learn what a marker will say. And the interpolation is not string-mashing: each {…} hole is a call to the pinned formatter, fmt.price, fmt.pct, fmt.num, fmt.time — one canonical routine shared byte-for-byte by the interpreter, the compiled module and the server. So a label reads the same on every engine, which matters the moment the same script runs in your editor, in a backtest and on a server that emails you the alert: the number in the message is the number everywhere. The formatter is covered in full in FDK — Text.

Sending it: alert

The same signal that draws a mark can leave the chart entirely. alert raises a message when its condition turns true:

FLUX
cross = close cross_up ema(close, 50)

mark  cross "crossed at {fmt.price(close)}"
alert cross "EMA-50 crossed up"

Nothing here is a second definition of the crossing — cross is written once and consumed twice. That is the point of giving truth its own kind: the thing you see and the thing you get notified about are provably the same event, because they are the same expression.

An alert is a decision, and a decision is exactly the kind of thing that must be trustworthy, so Flux draws one hard line around it. A decision may not read the currently-forming bar. Inside the editor you may show the forming value for display — plot live(ema(close, 20)) updates within the bar and is flagged non-replayable in the guarantees panel, visibly, at the moment you rely on it — but you may not let a forming value decide anything:

FLUX
// ✗ alert live(ema(close, 20)) > 100      — [ErrFirewall]: a decision may not read a forming value

The reason is the guarantee that makes an alert worth having: an alert that fired is exactly the alert a re-execution fires. A signal that read a value still in motion could flip after you acted on it — the classic repainting alert — and Flux makes that unwriteable rather than merely discouraged. [ErrFirewall] is not a lint; it is the absence of a way to build a lie. The firewall between the planes is Guide §8’s subject in depth.

Checking it: assert

The third use of a signal points inward. assert states an invariant you expect to hold on every bar, and it is how you check your own reasoning without leaving the language:

FLUX
assert rsi(close, 14) <= 100 "rsi is bounded"     // a self-check; `na` during warm-up passes

An assertion fires only on a signal that is definitely false. During warm-up, when the RSI is still na, the comparison is na too, and an na verdict counts as a pass — so an honest check never trips on the emptiness at the start of history, and you never have to guard it by hand. When you want the invariant on one specific bar rather than all of them, add at:

FLUX
assert cum(close - open) > 0 "equity stays positive" at 500

assert reads the same closed data every other decision reads, so a check that passes in your editor is a check that passes in the backtest and on the server. It is a signal used as a conscience: the same yes/no you would draw or send, turned on the program itself.

See also

The formal rules →

Everything this chapter narrated is specified exactly across the spec and the FDK: