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:
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:
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:
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:
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 noneLook 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.
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.
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:
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:
// ✗ alert live(ema(close, 20)) > 100 — [ErrFirewall]: a decision may not read a forming valueThe 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:
assert rsi(close, 14) <= 100 "rsi is bounded" // a self-check; `na` during warm-up passesAn 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:
assert cum(close - open) > 0 "equity stays positive" at 500assert 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
- Guide §5 — Kinds — why a comparison only types when both sides share an axis.
- Guide §4 — Streams, delay and running state —
na, warm-up, and why annaassertion passes. - Guide §6 — More than one clock — a fast chart, coloured by a slow trend: signals across clocks.
- Guide §8 — The four planes — the firewall that keeps a decision from reading a forming value.
- Guide §13 — Cookbook — divergences, multi-condition setups, and marked recipes that run.
- FDK — Text — the pinned
fmt.*formatter behind every interpolated label.
The formal rules →
Everything this chapter narrated is specified exactly across the spec and the FDK:
- Comparisons,
cross_up/cross_down, and thesignalkind →- Logic on signals —
and,or,not(and only signals) →- The ANALYSIS sinks —
mark,alert,assert— grammar and semantics →- Why a
signalis presented as marks and never a line →- What each sink accepts, and the
[ErrArg]boundary →- Causality: why a decision may not read the forming bar →
- The pinned formatter behind interpolated labels →