Part 2 of 3
Building an EMA Crossover System in TradingView with ScalpTrader TradeEvents Integration

Part 2: Implementing It in Pine Script

June 30, 2026

·

8 min read

TradingView

In Part 1, I covered the idea: an EMA crossover strategy used as a placeholder for a more general pattern — turning a TradingView strategy’s position changes into webhook events TradeEvents can consume. In this post, I’ll go through how that’s actually built in Pine Script, including the parts that don’t have a clean equivalent in something like NinjaScript: alert-based delivery, manually tracked position state, and a batched warmup replay.

From alert() to a webhook

Pine Script strategies can’t make HTTP calls directly. The only path data takes off a TradingView chart in real time is an alert, and alerts only carry a string payload — whatever you pass to alert(). The webhook delivery itself isn’t something the script controls; it’s configured once, on the alert object, when you point its notification target at a webhook URL.

That means every event this strategy “sends” is really just a call to alert() with a JSON string built by hand:

pine
if openedNew
    alert(openJson(curPositionId, dirWord(curSize), math.abs(curSize),
        strategy.position_avg_price, isoTime(time)), alert.freq_all)

The JSON-building functions are the closest equivalent to NinjaScript’s SendPositionOpened()/SendPositionClosed() from the earlier series — they just return a string instead of making a request themselves.

The event envelope

Every event this script sends shares the same outer shape:

json
{
  "system_name": "YOUR_SYSTEM_NAME",
  "event_type": "position_opened",
  "timestamp": "2026-06-30T14:32:00Z",
  "data": { ... }
}

system_name has to match what’s configured in the ScalpTrader dashboard exactly — it’s how TradeEvents knows which system’s events these are, since there’s no separate auth header the way there might be on a typical API call (the webhook URL itself carries the authentication). event_type tells the backend how to interpret data, and timestamp is built with a small helper that formats Pine’s bar time as ISO-8601 UTC:

pine
isoTime(t) => str.format_time(t, "yyyy-MM-dd'T'HH:mm:ss'Z'", "UTC")

The JSON builders themselves are just string concatenation — Pine doesn’t have a JSON-serialization library, so the strategy constructs each payload as a literal string:

pine
openJson(posId, dir, qty, price, ts) =>
    '{"system_name":"' + sysName + '","event_type":"position_opened","timestamp":"' + ts + '","data":{' +
      '"strategy":"' + sysName + '","symbol":"' + syminfo.ticker + '",' +
      '"direction":"' + dir + '","quantity":' + str.tostring(qty) +
      ',"entry_price":' + str.tostring(price) + ',"position_id":"' + posId + '"}}'

It’s not elegant, but it keeps the script dependency-free, and it means the entire TradeEvents integration lives in plain Pine with nothing external to install.

Tracking position state across bars

This is the part with no real NinjaScript equivalent. In the NinjaTrader version, OnExecutionUpdate handed the strategy a concrete fill with a price, a quantity, and an order name to key off of. Pine Script strategies don’t get fill callbacks — on each bar, you can read the current state (strategy.position_size, strategy.position_avg_price) but nothing about what just changed, and nothing like a stable position identifier.

So the script keeps its own state across bars using var, which in Pine persists a variable’s value between bar executions instead of resetting it:

pine
var int    tradeSeq      = 0
var string curPositionId = ""
var float  curEntryPrice = na

tradeSeq is just an incrementing counter; curPositionId is built from the ticker, that counter, and the current bar index, which is enough to make it unique without needing any external ID generator:

pine
tradeSeq      += 1
curPositionId := syminfo.ticker + "-" + str.tostring(tradeSeq) + "-" + str.tostring(bar_index)

This plays the same role positionId played in the NinjaScript version — a token that lets TradeEvents match a position_opened event to the position_closed event that eventually closes it out.

Detecting opens, closes, and reversals

Without a fill callback, the script has to infer what happened by comparing strategy.position_size on the current bar to what it was on the previous bar:

pine
prevSize = nz(strategy.position_size[1])
curSize  = strategy.position_size
closedPrev = prevSize != 0 and (curSize == 0 or math.sign(curSize) != math.sign(prevSize))
openedNew  = curSize != 0 and (prevSize == 0 or math.sign(curSize) != math.sign(prevSize))

[1] is Pine’s way of indexing the previous bar’s value of a series. The logic covers three cases:

  • Flat → position: openedNew only
  • Position → flat: closedPrev only
  • Long → short or short → long (a reversal): both fire on the same bar — the strategy treats this as a close of the old position immediately followed by an open of the new one, which matches how strategy.entry itself behaves under EMA crossover logic (a reversal doesn’t pass through flat first; it flips directly).
pine
if closedPrev
    entry  = nz(curEntryPrice, close)
    pnlPts = math.sign(prevSize) * (close - entry)
    pnlUsd = pnlPts * syminfo.pointvalue * math.abs(prevSize)
    alert(closeJson(curPositionId, dirWord(prevSize), math.abs(prevSize), close, pnlPts, pnlUsd, isoTime(time)), alert.freq_all)

if openedNew
    tradeSeq      += 1
    curPositionId := syminfo.ticker + "-" + str.tostring(tradeSeq) + "-" + str.tostring(bar_index)
    curEntryPrice := strategy.position_avg_price
    alert(openJson(curPositionId, dirWord(curSize), math.abs(curSize), strategy.position_avg_price, isoTime(time)), alert.freq_all)

Notice the close event uses the previous position’s direction and size (prevSize), while the open event that might immediately follow uses the new ones (curSize) — on a reversal bar, both branches run in this order, so the close is always reported before the new open that replaces it.

The card: status and liveness in one event

The card serves double duty: it’s the dashboard’s “what is this strategy doing right now” snapshot, and it’s also the liveness signal, since TradingView doesn’t have a separate heartbeat concept. It fires once per realtime bar, plus immediately on any position change so the dashboard doesn’t wait for the next bar to reflect a fresh trade:

pine
if barstate.isrealtime
    alert(cardJson(), alert.freq_once_per_bar)
pine
cardJson() =>
    sz = strategy.position_size
    string js = na
    if sz != 0
        dir     = sz > 0 ? "long" : "short"
        qty     = math.abs(sz)
        entryP  = nz(curEntryPrice, close)
        upnlUsd = math.sign(sz) * (close - entryP) * syminfo.pointvalue * qty
        js := '{"system_name":"' + sysName + '","event_type":"card","data":{' +
          '"strategy":"' + sysName + '","symbol":"' + syminfo.ticker + '",' +
          '"status":"in_position","direction":"' + dir + '","quantity":' + str.tostring(qty) +
          ',"entry_price":' + str.tostring(entryP) +
          ',"unrealized_pnl":' + str.tostring(upnlUsd, "#.##") + '}}'
    else
        js := '{"system_name":"' + sysName + '","event_type":"card","data":{' +
          '"strategy":"' + sysName + '","symbol":"' + syminfo.ticker + '",' +
          '"status":"active","state_detail":"Waiting for EMA crossover"}}'
    js

Because this fires every realtime bar regardless of whether anything changed, the chart’s timeframe directly determines how “live” the dashboard feels. A 1-minute chart means a card roughly every minute; a 1-hour chart means the dashboard could go most of an hour without hearing anything — which is the reasoning behind keeping this strategy on a small timeframe (1–5 min), independent of whatever timeframe would actually be appropriate for an EMA(20)/EMA(50) crossover on its own.

Backfilling history on go-live: the warmup flushIntermediate

This section is specific to EMACrossover_Full.pine. The Simple version skips this block entirely — the first confirmed realtime bar goes straight into the live-event logic below with no historical replay.

The last piece is the warmup replay mentioned in Part 1. The first time the script sees a confirmed realtime bar, it looks back over warmupHours of the strategy’s already-closed trade history and replays it as a burst of warmup-tagged events, before falling into the normal live-event logic for anything after:

pine
var bool flushed = false
if barstate.isrealtime and barstate.isconfirmed and not flushed
    flushed := true
    total   = strategy.closedtrades
    cutoff  = timenow - warmupHours * 60 * 60 * 1000
    startIdx = total
    for i = 0 to total - 1
        if strategy.closedtrades.exit_time(i) >= cutoff
            startIdx := i
            break
    if total - startIdx > warmupMax
        startIdx := total - warmupMax
    ...

strategy.closedtrades and its accessors (entry_price, exit_time, etc.) are Pine’s built-in trade history — available without the script having tracked anything itself, which is what makes the replay possible even though live trades need the manual var tracking described above. The loop walks backward to find the earliest closed trade inside the warmupHours window, then clamps it further with warmupMax as a hard safety cap on how many trades get replayed in one burst.

The trickiest part is what comes next: Pine caps a single string at 4096 characters, and the script is about to potentially send dozens of trades worth of JSON in one shot. So instead of one alert() call per trade (which would risk hitting TradingView’s alert-frequency limits), pairs of open/close JSON objects get accumulated into a batch, flushed as a JSON array the moment adding the next pair would push the batch over a safe threshold:

pine
pair = oObj + "," + cObj
if str.length(arr) > 0 and str.length(arr) + str.length(pair) > 3500
    alert("[" + arr + "]", alert.freq_all)
    arr := ""
arr := arr + (str.length(arr) > 0 ? "," : "") + pair

The 3500 cutoff leaves headroom under Pine’s 4096 limit for the surrounding [...] and whatever the next pair would add, rather than calculating the exact remaining budget on every iteration.

Once the backlog is flushed, if there’s a position currently open, it’s sent as a live (non-warmup) position_opened event — stamped with the current time rather than its historical entry time, so it sorts as the newest item on the dashboard rather than getting buried in the replayed history.

Constraints worth keeping in mind

A few things that are easy to miss reading through this quickly:

  • Confirmed-bar-only entries. Both the EMA crossover logic and the live-event detection gate on barstate.isconfirmed, which avoids firing (and reporting) on a value that might still repaint before the bar closes.
  • The 4096-character alert cap. This is a hard Pine Script limit, not a configurable one — any TradeEvents integration sending batched data through alerts needs to account for it, not just this warmup case.
  • Timeframe affects liveness, not just signal quality. Because the card is the liveness signal, the choice of chart timeframe is a monitoring decision as much as a trading one.

What’s next

The strategy now turns position changes into a stream of JSON-shaped alerts, with persistent state carried across bars and a batched replay for trade history. In Part 3, I’ll cover the one-time setup in both TradingView and the ScalpTrader dashboard, walk through each event payload in full, and show what it looks like once it’s actually live.