Tutorial 2 September 2026 · About 13 minutes

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:

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

  1. 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.
  2. Fire every branch — long entry, long exit, short entry, short exit, and any bracket legs. This is how you find the alert_message arguments you forgot.
  3. 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.
  4. Send a crypto pair as BTCUSD with time_in_force of day, and confirm your normalisation catches both errors.
  5. 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 trial

No credit card required. Works with an Alpaca paper account, so you can watch it run before anything is at stake.

The short version

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.