Alerts
Wire your script conditions into push / Telegram / in-app notifications via alertcondition. Backend evaluation, frequency modes, message placeholders, and the FE preview no-op.
alertcondition lifts a boolean expression into the alert builder. The user wires it to push, Telegram, or in-app notifications from the per-indicator alerts dialog the same way they wire alerts on any built-in indicator (MACD signal cross, Bollinger break, Supertrend flip, etc.). Alert evaluation happens on the backend, not in the chart frontend — so the alert fires whether or not the user has the chart open.
How alerts flow
- The script is saved (or auto-compiled) in the editor.
- The BE compiler scans for every
alertcondition(...)call and writes an entry to the script'salert_manifest. - The user opens the alert builder (per-indicator alerts dialog, or
/trading/alerts). The manifest's entries appear as wire-up options. - The user picks channels (push / Telegram / in-app), sets frequency, hits enable.
- The BE indicator-engine evaluates the condition on every bar close (or every tick, depending on frequency). Hits stream to the user's chosen channels.
The frontend's job is purely visual — it shows you the conditions that exist and lets the user wire them. It does NOT fire alerts itself.
Frontend preview. The FE interpreter exposes
alertcondition(andalert) as no-op functions so the same script source compiles in both the BE alert evaluator and the FE chart preview. You can call them anywhere; in preview they have zero effect.
alertcondition
alertcondition(condition,
title="…",
message="…",
frequency="once_per_close",
name="…")
| Argument | Type | Default | Description |
|---|---|---|---|
condition | series<boolean> (positional, required) | — | Per-bar truth value. The alert fires when this is true for the most recent (or just-closed) bar. |
title | string | <script @name> | Alert title shown in the wire-up UI. Pick something short and descriptive — this is what the user sees in the alert builder. |
message | string | "" | Notification body. Supports {{…}} placeholders (see below). |
frequency | string | "once_per_close" | When the BE evaluates and (if true) fires. See below. |
name | string | slug(<title>) | Stable identifier for the entry in the manifest. Derivation order: explicit name= → slug(title) → alert_<index>. Must be unique within the script. See Naming & duplicates for why dynamic titles often need an explicit name=. |
Message placeholders
The BE substitutes the following placeholders in the message string at fire time:
| Placeholder | Substituted with |
|---|---|
{{symbol}} | The chart's symbol (e.g. BTCUSDT) |
{{exchange}} | The exchange id (e.g. binance, bybit) |
{{interval}} / {{timeframe}} | The chart's timeframe (e.g. 1h) |
{{close}} / {{price}} | The closing price of the firing bar |
{{open}} / {{high}} / {{low}} | OHLC of the firing bar |
{{volume}} | Bar volume |
{{time}} | Bar timestamp (ISO 8601 UTC) |
alertcondition(crossover(close, sma(close, 50)),
title="Cross above MA50",
message="{{symbol}} crossed above MA50 at {{price}} ({{time}})")
Frequency modes
| Frequency | Fires when |
|---|---|
"once_per_close" (default) | The condition is true on a CLOSED bar. Default — typically what users want for "trend flipped" / "signal crossed back" / "breakout confirmed" alerts. |
"once_per_bar" | The condition becomes true for the first time on the current (still-forming) bar — fires once per bar even if the condition flickers in and out as ticks arrive. |
"every" | Every tick where the condition is true. Use sparingly — pair with a real cooldown elsewhere or you'll get a notification stream. |
"once" | Fires the FIRST time the condition becomes true after the alert is enabled, then disables itself. |
"once_per_close" is the safest default for most strategies. Switch to "once_per_bar" only if your traders need intra-bar firing and you've thought about the duplicate-event story (e.g. a fast-moving cross can fire then un-fire then fire again before the bar closes — "once_per_close" waits for the truth and "once_per_bar" accepts the first hit).
alert
alert(message, frequency="once_per_close") is the imperative Pine-style sibling of alertcondition. The FE no-ops it the same way; the BE may treat it as a higher-frequency channel without a per-bar gate.
if crossover(close, sma(close, 50))
alert("MA50 cross on {{symbol}}", frequency="once_per_bar")
end
For most workflows, prefer alertcondition — it's declarative and the alert builder UI knows how to render it as a wire-up option.
Worked example: MACD zero-cross with trend filter
A complete, copy-pasteable alert script. Fires when MACD histogram crosses zero (signal/main line agreement) AND price is on the right side of a slow EMA (trend filter). Two alerts — one for the long flip, one for the short flip.
@name "MACD Zero-Cross + Trend Filter"
@pane "overlay"
@input fastLen = input.int(12, "MACD fast", minval=2, maxval=200)
@input slowLen = input.int(26, "MACD slow", minval=2, maxval=400)
@input sigLen = input.int(9, "MACD signal", minval=1, maxval=100)
@input trendLen = input.int(200, "Trend EMA", minval=20, maxval=400)
macdLine = ema(close, fastLen) - ema(close, slowLen)
signalLine = ema(macdLine, sigLen)
hist = macdLine - signalLine
trend = ema(close, trendLen)
longFlip = crossover(hist, 0) and close > trend
shortFlip = crossunder(hist, 0) and close < trend
plotLine(trend, color="#F0B90B", width=1.5)
plotShape(longFlip, low, shape="arrowUp", color="#0ECB81", size=10)
plotShape(shortFlip, high, shape="arrowDown", color="#F6465D", size=10)
alertcondition(longFlip,
title="MACD long flip",
message="LONG flip on {{symbol}} ({{interval}}) at {{price}} — above EMA",
name="macd_long_flip",
frequency="once_per_close")
alertcondition(shortFlip,
title="MACD short flip",
message="SHORT flip on {{symbol}} ({{interval}}) at {{price}} — below EMA",
name="macd_short_flip",
frequency="once_per_close")
Save the script. The BE compiler picks up both alertcondition calls, registers them in the manifest, and the alert builder shows "MACD long flip" and "MACD short flip" as two separate wire-up entries. The user wires the long flip to push, the short flip to Telegram, hits enable on both, walks away. On every bar close the BE evaluates both conditions; whichever fires sends to its wired channels with the rendered message.
Multiple conditions in one script
You can declare any number of alertcondition calls — each one becomes its own entry in the manifest:
ma = sma(close, 50)
upCross = crossover(close, ma)
dnCross = crossunder(close, ma)
plotLine(ma, color="#F0B90B", width=2)
plotShape(upCross, low, shape="arrowUp", color="#0ECB81", size=8)
plotShape(dnCross, high, shape="arrowDown", color="#F6465D", size=8)
alertcondition(upCross, title="MA50 cross up", message="{{symbol}} crossed up MA50")
alertcondition(dnCross, title="MA50 cross down", message="{{symbol}} crossed down MA50")
Two alert entries appear; the user can wire them independently (different channels, different frequencies, different cooldowns).
Cookbook
Drop-in alert recipes you can paste into a script and adapt. None of these reference indicators we keep proprietary — every primitive comes from the stdlib.
Bollinger band breakout with volume confirmation
Fires when price closes outside the upper / lower band AND volume is at least 1.5× its 20-bar average — a classic breakout-versus-fake filter.
@input bbLen = input.int(20, "BB length", minval=5, maxval=200)
@input bbMult = input.float(2.0, "BB mult", minval=0.5, maxval=5.0)
@input volMul = input.float(1.5, "Vol multi", minval=1.0, maxval=10.0)
basis = sma(close, bbLen)
dev = stdev(close, bbLen) * bbMult
upper = basis + dev
lower = basis - dev
avgVol = sma(volume, bbLen)
bigVol = volume > avgVol * volMul
upBreak = close > upper and bigVol
dnBreak = close < lower and bigVol
plotLine(upper, color="#F6465D", width=1)
plotLine(lower, color="#0ECB81", width=1)
plotLine(basis, color="#F0B90B", width=1)
alertcondition(upBreak,
title="BB break up",
message="{{symbol}} broke ABOVE upper band on {{interval}} @ {{price}} (vol confirmed)",
name="bb_break_up")
alertcondition(dnBreak,
title="BB break down",
message="{{symbol}} broke BELOW lower band on {{interval}} @ {{price}} (vol confirmed)",
name="bb_break_dn")
Supertrend flip
Trend-following classic — fires when the supertrend direction flips. Most traders pair this with a higher-timeframe filter; the once_per_close frequency keeps it from re-firing on intra-bar wiggles.
@input atrLen = input.int(10, "ATR length", minval=2, maxval=100)
@input atrMul = input.float(3.0, "ATR mult", minval=0.5, maxval=10.0)
atrV = atr(atrLen)
hl2 = (high + low) / 2
upBand = hl2 - atrV * atrMul
dnBand = hl2 + atrV * atrMul
trendUp = close > nz(dnBand[1], dnBand)
trendDn = close < nz(upBand[1], upBand)
direction = nan
if trendUp
direction := 1
end
if trendDn
direction := -1
end
flipUp = direction == 1 and direction[1] == -1
flipDown = direction == -1 and direction[1] == 1
plotShape(flipUp, low, shape="arrowUp", color="#0ECB81", size=10)
plotShape(flipDown, high, shape="arrowDown", color="#F6465D", size=10)
alertcondition(flipUp,
title="Supertrend → long",
message="Supertrend flipped LONG on {{symbol}} ({{interval}}) @ {{price}}",
name="st_flip_long")
alertcondition(flipDown,
title="Supertrend → short",
message="Supertrend flipped SHORT on {{symbol}} ({{interval}}) @ {{price}}",
name="st_flip_short")
Volume spike (statistical outlier)
Fires when the bar's volume is more than N standard deviations above the rolling mean. Useful as an "unusual activity" alert when paired with a price filter.
@input look = input.int(50, "Lookback", minval=10, maxval=500)
@input zMin = input.float(3.0,"Z-score gate", minval=1.5, maxval=10.0)
mean = sma(volume, look)
sd = stdev(volume, look)
z = (volume - mean) / sd
bigUp = z > zMin and close > open
bigDn = z > zMin and close < open
plotShape(bigUp, low, shape="circle", color="#0ECB81", size=8)
plotShape(bigDn, high, shape="circle", color="#F6465D", size=8)
alertcondition(bigUp,
title="Bullish volume spike",
message="Unusual BUY volume on {{symbol}} ({{interval}}) @ {{price}} — z>{{zMin}}",
name="vol_spike_bull")
alertcondition(bigDn,
title="Bearish volume spike",
message="Unusual SELL volume on {{symbol}} ({{interval}}) @ {{price}} — z>{{zMin}}",
name="vol_spike_bear")
Note. The
{{zMin}}placeholder in the message above does NOT auto-substitute the input value — only the placeholders in Message placeholders get substituted. Bake the threshold into the literal text if you want it shown (e.g."z>3.0") or wait for a future BE version that exposes runtime expressions in messages.
EMA ribbon agreement (multi-bar gate)
Fires only when ALL three EMAs are stacked in trend order — a stronger filter than a single MA cross. The condition is gated through barssince so an alert fires once per regime change, not continuously while stacked.
e21 = ema(close, 21)
e50 = ema(close, 50)
e200 = ema(close, 200)
stackedUp = e21 > e50 and e50 > e200
stackedDn = e21 < e50 and e50 < e200
// Fire only on the FIRST bar the stack flips (regime change)
firstUp = stackedUp and not stackedUp[1]
firstDn = stackedDn and not stackedDn[1]
plotLine(e21, color="#0ECB81", width=1)
plotLine(e50, color="#F0B90B", width=1)
plotLine(e200, color="#F6465D", width=1.5)
alertcondition(firstUp,
title="EMA ribbon stacked long",
message="EMA ribbon flipped LONG on {{symbol}} ({{interval}}) @ {{price}}",
name="ema_stack_long")
alertcondition(firstDn,
title="EMA ribbon stacked short",
message="EMA ribbon flipped SHORT on {{symbol}} ({{interval}}) @ {{price}}",
name="ema_stack_short")
Pivot break (manual structure trader)
Fires when price closes through a recent pivot high / low. The pivots come from pivothigh / pivotlow with NaN-aware valuewhen to pin the most-recent confirmed level.
@input pivotL = input.int(5, "Pivot left", minval=2, maxval=20)
@input pivotR = input.int(5, "Pivot right", minval=2, maxval=20)
ph = pivothigh(high, pivotL, pivotR)
pl = pivotlow(low, pivotL, pivotR)
lastPH = valuewhen(ph == ph, high, 0) // NaN-aware "isPivot"
lastPL = valuewhen(pl == pl, low, 0)
breakUp = crossover(close, lastPH)
breakDn = crossunder(close, lastPL)
plotShape(breakUp, low, shape="arrowUp", color="#0ECB81", size=10)
plotShape(breakDn, high, shape="arrowDown", color="#F6465D", size=10)
alertcondition(breakUp,
title="Pivot break up",
message="{{symbol}} broke ABOVE last pivot high on {{interval}} @ {{price}}",
name="pivot_break_up")
alertcondition(breakDn,
title="Pivot break down",
message="{{symbol}} broke BELOW last pivot low on {{interval}} @ {{price}}",
name="pivot_break_dn")
Naming & duplicates
Every alertcondition ends up in the manifest under a stable name. The BE derives it in this order:
- Explicit
name="…"kwarg — used verbatim (after slugifying). - Slug of
title="…"if noname=is given — lowercase, non-alphanumeric →_. alert_<index>as a last-resort fallback when neither is provided.
Two alertcondition calls that resolve to the same manifest name fail the save with HTTP 422:
{ "error": "compile_failed", "message": "parse: line 89: duplicate alertcondition name: new" }
There are two ways to trip this rule.
1. Two identical titles
// ❌ Both slug to "ma_cross" → 422 duplicate.
alertcondition(upCross, title="MA cross")
alertcondition(dnCross, title="MA cross")
Fix: give each call a unique title (preferred — the user sees it in the alert builder), or add an explicit name=:
// ✅ Unique titles.
alertcondition(upCross, title="MA cross up")
alertcondition(dnCross, title="MA cross down")
// ✅ OR explicit names with shared title.
alertcondition(upCross, title="MA cross", name="ma_cross_up")
alertcondition(dnCross, title="MA cross", name="ma_cross_dn")
2. Dynamic titles that share a literal prefix
The BE compiler runs at save time, before any of the script's series have evaluated. It can't compute string concatenation involving runtime variables (like an @input value), so it slugifies only the literal prefix of the title expression:
// ❌ Both titles start with the literal "New ".
// BE sees the slug as "new" for both → 422 duplicate.
alertcondition(triggerA, title="New " + htf + " bar open above prev high")
alertcondition(triggerB, title="New " + htf + " bar open below prev low")
This is the most common cause of the duplicate-name error in scripts that template their titles from inputs. Fix it by providing an explicit name= per call — the title can still be dynamic for the user-facing display:
// ✅ Explicit name= disambiguates at compile time.
// title= remains dynamic for the alert builder UI.
alertcondition(triggerA,
title="New " + htf + " bar open above prev high",
name="htf_open_above_prev_high")
alertcondition(triggerB,
title="New " + htf + " bar open below prev low",
name="htf_open_below_prev_low")
Recommended practice
- For scripts with a single alert, you can skip
name=— the slugified title is fine. - For scripts with multiple alerts, ALWAYS give each one an explicit
name=. This stays stable across title edits, which matters because the user's saved wire-ups in/v1/alertsreference the alert byname— renaming the title (or letting the slug shift) without a stablename=will orphan existing alerts. - Use snake_case for
name=values (e.g.bull_cross,htf_break_high). The slug rule lowercases and collapses non-alphanumeric runs into_anyway, so anything else gets normalised.
Anti-patterns
Don't gate alertcondition behind an if block
// ❌ Don't — alertcondition is registered at compile time.
if isLive
alertcondition(crossUp, title="Live cross")
end
The BE compiler scans the source statically; an alertcondition call inside a conditional if block STILL ends up in the manifest. The runtime gating doesn't filter what shows up in the alert builder.
If you want a condition to be evaluated only sometimes, gate the condition expression instead:
// ✅ Right.
liveCondition = isLive and crossover(close, ma)
alertcondition(liveCondition, title="Live cross")
Don't put per-tick state in the message
The BE evaluates the condition (and substitutes placeholders) on the firing bar. Anything else — your indicator's internal state, a counter — won't appear in the message. Stick to the {{…}} placeholders.
Don't expect the FE to fire alerts
Editing a condition in the script editor does NOT fire test notifications, even if the condition would be true on the current chart. Alerts only fire from the BE evaluator, which runs against the user's stored alert wire-ups. To verify a condition is wiring up correctly, save the script, open the alert builder, and confirm the entry shows up.
Next
- Debugging —
logInfo/logWarning/logErrorfor auditing the conditions you wire to alerts before you enable them. - Versioning — what happens to existing alerts when you edit the script.
- Recipes — full alert-driven scripts.
- Chart Terminal overview — where alert notifications land in the trader's UI.