Pine Script Strategy Alerts to Alpaca: the Message That Actually Places the Order
Alerts on a Pine Script strategy behave differently from alerts on an indicator, and
most guides quietly assume the indicator case. That mismatch is why the alert log shows a
delivery, your endpoint logs an empty body, and no order reaches Alpaca. This is the specific
version: which placeholders work in which alert type, the alert_message trap that
produces blank payloads, and how to map what a strategy sends onto what Alpaca's order
endpoint expects.
If you have not built the receiving end yet, start with how to auto-execute TradingView alerts on Alpaca, which covers the webhook basics and a minimal Flask bridge. This picks up where that one stops: you have an endpoint, it works when you fire an indicator alert by hand, and it does nothing useful when you point a strategy at it.
Everything here targets a paper account. Alpaca's paper endpoint is
https://paper-api.alpaca.markets and the API surface is identical to live, so
every example below can be run without money at stake.
Three kinds of strategy alert, and picking the wrong one
When you open Create alert and select a strategy in the Condition field, TradingView offers three options, and they are not interchangeable:
- Order fills and alert() function calls — fires on both.
- Order fills only — fires when the broker emulator executes a simulated order.
- alert() function calls only — fires only where the script explicitly calls
alert().
For execution you almost always want order fills. An order fill event is the
emulator's equivalent of a broker filling your trade. The Pine documentation is blunt about
the alternative: alert() calls "cannot be coded to trigger when orders are
executed (or filled) because after orders are sent to the broker emulator, the emulator
controls their execution and does not report fill events back to the script directly."
This matters because a strategy using stop or limit entries places
an order on one bar and may fill it many bars later — or never. Drive execution from
alert() calls sitting next to your strategy.entry() logic and you
are acting on order submission, not order execution; your Alpaca position
will drift away from the simulated one.
There is a fourth thing people try, and it does not work at all:
alertcondition(). It compiles inside a strategy without complaint, but the
documentation states that "alerts cannot be created from them". If you copied a
snippet from an indicator and wondered why the condition never appears in the dropdown, that
is why.
Which placeholders work where
TradingView substitutes {{...}} placeholders into the alert message before
sending the request. The strategy-prefixed ones are the useful part, and per TradingView's
reference they "can only be used in strategy alerts". Put one in an indicator alert
and it is sent through as literal text, which breaks your JSON parser.
| Placeholder | Resolves to | Use it for |
|---|---|---|
{{strategy.order.action}} |
buy or sell |
Alpaca's side, directly |
{{strategy.order.contracts}} |
Size of the order that just filled | Alpaca's qty — with the caveat below |
{{strategy.position_size}} |
Signed position size after the fill | Reconciling against your real position |
{{strategy.market_position}} |
long, flat or short |
Classifying opening versus closing |
{{strategy.prev_market_position}} |
The position before this fill | Detecting a reversal |
{{strategy.order.id}} |
The id string from strategy.entry() and friends |
Idempotency keys, routing |
{{ticker}} |
Symbol without exchange, e.g. AAPL |
Alpaca's symbol — after normalisation |
{{timenow}} |
UTC yyyy-MM-ddTHH:mm:ssZ, to the second |
Staleness checks, deduplication |
Two omissions in that table are deliberate. {{interval}} returns
"1" rather than the chart timeframe for price-based alerts and on Range charts,
so do not route on it. And {{time}} is the timestamp of the bar, not of
the alert — {{timenow}} is the firing time, and the one to use for staleness
checks.
A payload composed in the alert dialog
This is the version to start with, because it needs no changes to the Pine script. Paste it into the Message field of an alert created on order fills:
{
"secret": "a-long-random-string-you-generate",
"symbol": "{{ticker}}",
"action": "{{strategy.order.action}}",
"quantity": {{strategy.order.contracts}},
"position_after": {{strategy.position_size}},
"was": "{{strategy.prev_market_position}}",
"now": "{{strategy.market_position}}",
"order_id": "{{strategy.order.id}}",
"fired_at": "{{timenow}}"
}
Note the quoting. action resolves to text so it is wrapped in quotes;
quantity and position_after resolve to numbers so they are not. Get
that backwards and TradingView sends malformed JSON, your endpoint returns 400, and the alert
log records a delivery failure with no further explanation. Related: TradingView sets
Content-Type: application/json only when the body is valid JSON, and
text/plain otherwise — so if your framework parses JSON only on the correct
content type, one stray comma becomes a silent no-op.
A payload composed in Pine
The alternative is to build the message inside the script with the alert_message
parameter, available on strategy.entry(), strategy.exit(),
strategy.order() and strategy.close(). Because the argument is a
series string, you can compute it at runtime:
//@version=6
strategy("EMA cross, webhook messages", overlay = true)
fast = ta.ema(close, 9)
slow = ta.ema(close, 21)
size = 10
payload(action, qty) =>
'{"secret":"REPLACE_ME"' +
',"symbol":"' + syminfo.ticker +
'","action":"' + action +
'","quantity":' + str.tostring(qty) +
',"order_id":"' + action + '-' + str.tostring(time) + '"}'
if ta.crossover(fast, slow)
strategy.entry("Long", strategy.long, qty = size,
alert_message = payload("buy", size))
if ta.crossunder(fast, slow)
strategy.close("Long",
alert_message = payload("sell", size))
Then set the alert's Message field to exactly this, and nothing else:
{{strategy.order.alert_message}}
The empty-string trap. TradingView's documentation states that when the
Message field contains only {{strategy.order.alert_message}} and the
alert_message parameter is present in only some of your
order-generating calls, "an empty string will replace the placeholder" for the
calls that omit it. Your endpoint receives a request with a blank body. Nothing errors, the
alert log shows a successful delivery, and no order is placed. If you use
alert_message anywhere, put it on every
strategy.*() call that can generate an order — including the exit legs of a
bracket, which are the easiest to forget.
One more constraint on this route: other placeholders "cannot be used in the argument of
alert_message". You cannot write {{ticker}} inside the Pine
string — use syminfo.ticker, as above. Mixing the two idioms is why payloads
arrive with literal braces still in them.
Mapping a strategy payload onto an Alpaca order
Now the broker side. Three fields need real translation rather than a straight copy.
Symbols: TradingView's format is not Alpaca's
For equities this is a non-issue — {{ticker}} gives you AAPL and
Alpaca wants AAPL. For crypto it breaks immediately. TradingView sends
BTCUSD; Alpaca's documented symbology is BTC/USD,
with a slash. The legacy concatenated form is still accepted for backward compatibility, but
the assets API returns the slashed form and that is what to normalise to.
Do not hard-code quote currencies and slice the string. Ask Alpaca:
import os
import requests
ALPACA_BASE = "https://paper-api.alpaca.markets/v2"
ALPACA_HEADERS = {
"APCA-API-KEY-ID": os.environ["ALPACA_KEY_ID"],
"APCA-API-SECRET-KEY": os.environ["ALPACA_SECRET_KEY"],
}
def load_crypto_symbols():
"""Alpaca's own asset list is the only reliable source of symbology."""
response = requests.get(
f"{ALPACA_BASE}/assets",
params={"asset_class": "crypto", "status": "active"},
headers=ALPACA_HEADERS,
timeout=10,
)
response.raise_for_status()
table = {}
for asset in response.json():
pair = asset["symbol"] # "BTC/USD"
table[pair] = pair
table[pair.replace("/", "")] = pair # "BTCUSD" -> "BTC/USD"
return table
CRYPTO = load_crypto_symbols()
Time in force: crypto and equities do not accept the same values
Alpaca accepts gtc and ioc for crypto. It does not accept
day, opg, cls or fok there. Send
day with a crypto order and it is refused, which in a hurry looks exactly like a
symbol problem — unless you are reading the response body.
def to_alpaca_order(payload):
raw = payload["symbol"].upper()
symbol = CRYPTO.get(raw, raw)
is_crypto = symbol in CRYPTO
return {
"symbol": symbol,
"qty": str(payload["quantity"]),
"side": payload["action"], # already "buy" or "sell"
"type": "market",
# Crypto accepts gtc and ioc only; day is an equities concept.
"time_in_force": "gtc" if is_crypto else "day",
}
Quantity: contracts is not the same question as position size
{{strategy.order.contracts}} is the size of the order that just filled in the
simulation. {{strategy.position_size}} is where the strategy expects to
be afterwards. The two diverge the moment your real account and the simulated one disagree:
after a rejection, after a partial fill, after you closed something by hand on your phone.
Reconciling to the target position is the more robust design, and it is only a few lines:
def reconcile(payload):
"""Trade the difference between where we are and where the strategy
expects to be, rather than trusting the delta we were sent."""
raw = payload["symbol"].upper()
symbol = CRYPTO.get(raw, raw)
target = float(payload["position_after"])
response = requests.get(
f"{ALPACA_BASE}/positions/{symbol}",
headers=ALPACA_HEADERS,
timeout=10,
)
# A 404 means "no position", which is not an error here.
current = float(response.json()["qty"]) if response.status_code == 200 else 0.0
delta = target - current
if abs(delta) < 1e-9:
return None # already aligned, do nothing
return {
"symbol": symbol,
"qty": str(abs(delta)),
"side": "buy" if delta > 0 else "sell",
"type": "market",
"time_in_force": "gtc" if symbol in CRYPTO else "day",
}
This design fixes reversals for free. A strategy flipping from long 10 to short 10 gives a
target of -10, a delta of -20, and one order instead of two. It also
makes a duplicated alert harmless: the second time, the delta is zero and nothing is sent.
Alpaca does not permit short crypto positions, and crypto is not
marginable — orders are evaluated against non_marginable_buying_power. Point a
long/short Pine strategy at a crypto pair and every short leg is refused. Clamp the target
at zero for crypto, or run a long-only strategy there.
The alert fired and Alpaca still said no
These are the rejections that specifically bite strategy-driven order flow, as opposed to manual trading. Each is documented behaviour rather than a fault.
Wash-trade protection, HTTP 403
Alpaca checks every order for a potential wash trade and rejects with 403 if it finds one. The rule concerns orders that could interact: an open market buy plus a new market sell on the same security is always rejected, and a limit buy plus a limit sell is rejected if the buy price is at or above the sell price.
A Pine strategy triggers this far more often than a human does, because strategies commonly hold a resting exit while placing a new entry:
{"code": 40310000,
"message": "potential wash trade detected. use complex orders",
"reject_reason": "opposite side market/stop order exists"}
The message contains the fix. Bracket orders, OCO orders and trailing stops are explicit
exceptions to wash-trade protection, so translate a strategy.exit() carrying both
a target and a stop into a bracket rather than two independent orders. Note also that this
protection applies to crypto, and that it applies in paper — so it is one
failure you can and should reproduce before going live.
Fractional and notional constraints
Fractional equity orders accept qty to nine decimal places, but require
time_in_force of day, and cannot be sold short. Supplying both
qty and notional in one request returns a 400. And not every symbol
is fractionable — check the fractionable flag on the asset first, or you will get
requested asset is not fractionable. A Pine strategy sizing by percent of equity
produces fractional quantities constantly, so this is not an edge case.
Extended hours and orders after the close
Only limit orders with time_in_force of day or gtc are
eligible for extended hours; everything else is rejected. An ineligible order submitted after
16:00 ET is queued for the next trading day rather than refused, which is arguably worse: a
market order from an evening alert on a 4-hour chart executes at the next open, at whatever
price that turns out to be. If your strategy's bars close outside regular hours, decide
deliberately what should happen, and have the endpoint refuse rather than queue.
Sub-penny increments
If you forward {{strategy.order.price}} into a limit order, round it. Limit and
stop prices at or above $1.00 accept two decimal places; below $1.00, four. Pine hands you
290.123 without hesitating, and Alpaca answers
42210000: invalid limit_price 290.123. sub-penny increment does not fulfill minimum
pricing criteria.
Timing, frequency and the three-minute kill switch
Alerts only trigger on realtime bars, and how often they can trigger within one largely
determines whether your live fills resemble your backtest. For the alert()
function, the frequency constants are alert.freq_once_per_bar (the default),
alert.freq_once_per_bar_close and alert.freq_all. There is an
override that catches almost everyone: unless calc_on_every_tick = true is set in
the strategy() declaration, all alert() calls in a strategy
use alert.freq_once_per_bar_close "regardless of the argument used for
freq". Setting freq and seeing no change is documented
behaviour, not a bug.
Turning calc_on_every_tick on to get faster alerts is a trade to make with your
eyes open. TradingView's own repainting guide files most strategies using it under
"potentially misleading", and states the position plainly: "There is no panacea; avoiding
this type of repainting always entails waiting for confirmed information." A condition
true mid-bar and false at the close will send you an order you would never have taken.
Fifteen triggers in three minutes and the alert stops itself. TradingView
states that "to prevent system spam, if a script alert triggers more than 15 times
within a three-minute window, it will automatically stop." A strategy oscillating on a
1-minute chart with calc_on_every_tick enabled can reach that inside one
volatile candle, and the only place you will notice is the alert panel.
There is also a hard three-second budget on your side: TradingView cancels the request if the receiving server takes longer than three seconds to respond. Placing an Alpaca order inline, inside the webhook handler, can exceed that under load — and a cancelled request is a delivery failure with nothing to fall back on, since TradingView documents no retry mechanism. Acknowledge with a 200 immediately, queue the work, and place the order outside the request cycle.
Two reasons a working setup stops working
Alerts are frozen snapshots of your script
This is the answer to a large share of "my alert broke and I changed nothing" threads. When you create an alert, TradingView "saves a mirror image of the script and its inputs, along with the chart's main symbol and timeframe" and runs that copy on its servers. Later edits to your Pine code or inputs do not affect alerts already running. Fix a bug and you must delete the alert and create a new one, or the server keeps executing the old logic.
Non-standard chart types
Running a strategy on Heikin Ashi or Renko and routing the fills to a broker is a category error, and TradingView says so directly: those values "are synthetic; they are not actual market prices" and are "unsuited to backtesting or automated trading, as orders execute on market prices — not Heikin-Ashi prices." Such strategies sit in TradingView's own "unacceptable repainting" category, and no work on the bridge will fix that.
A test sequence that actually finds the problems
- Point the alert at a request-inspection endpoint first, not at your bridge, and read the exact bytes arriving. Half of all placeholder problems are visible here in thirty seconds.
-
Fire every branch — long entry, long exit, short entry, short exit, and any bracket legs.
This is how you find the
alert_messagearguments you forgot. - Provoke a wash-trade rejection deliberately: leave a resting exit order in place and send an opposing market order. Confirm you learn about the 403 without reading a log file.
-
Send a crypto pair as
BTCUSDwithtime_in_forceofday, and confirm your normalisation catches both errors. -
After a week on paper, compare positions. Any drift between
{{strategy.position_size}}and Alpaca is a real bug, and better found now.
Bear in mind what paper does not model. Alpaca lists the gaps explicitly: market impact, information leakage, slippage due to latency, queue position for non-marketable limit orders, price improvement, regulatory fees and dividends. Borrow fees are absent too. Paper tells you whether your plumbing is correct, not what your strategy is worth.
If you would rather not maintain the mapping
TradingMixer receives your TradingView or TrendSpider alert and places the corresponding order in your own Alpaca account. You keep the strategy and the Pine code; we run the layer described here — payload validation, symbol normalisation for stocks and crypto, opening-versus-closing classification, failure emails when the broker refuses an order, and a kill switch. It is non-custodial: your money stays at your broker, and we do not decide what you trade.
One plan, $9/month, stocks and crypto, paper or live. New positions are rate-limited to one per webhook per 15 minutes, which suits swing and position trading on hourly-or-slower charts. Orders that close a position are never rate-limited.
It is not for you if you are scalping sub-minute timeframes, need a broker other than Alpaca, trade options, or want per-leg control over brackets constructed in Pine. For those, the DIY route above is the better answer, and you now know most of what it involves.
Start a 30-day free trialNo credit card required. Works with an Alpaca paper account, so you can watch it run before anything is at stake.
The short version
- Create the alert on order fills, not on
alert()calls. {{strategy.*}}placeholders work only in strategy alerts, never inside analert_messageargument.- Use
alert_messageon every order-generating call, or on none. - Normalise crypto to
BTC/USDwithgtcorioc. - Reconcile to
{{strategy.position_size}}, notcontracts. - Answer within three seconds; place the order afterwards.
- Recreate alerts after editing the script — the running alert is a frozen copy.
Trading carries substantial risk of loss, and automation does not reduce it. It removes the pause in which you might have noticed something was wrong, and it executes flawed logic faster than you can intervene. Automate a strategy you already understand, run it on paper until the plumbing bores you, and size it as though it will fail. Our full risk disclosure sets out the position in detail.