Insights/Insights & Articles/Building Trading Bots in Pine Script: TradingView Strategy Development

Building Trading Bots in Pine Script: TradingView Strategy Development

If you want the fastest way to test a trading idea, Pine Script is it. No compilers, no broker APIs, no setting up MetaTrader terminals — just open TradingView, write some code, and you've got a backtest running in seconds. We've used it for years as our first stop before porting anything serious to MQL4 or Python.

Apr 6, 2026

Building Trading Bots in Pine Script: TradingView Strategy Development

Building Trading Bots in Pine Script: TradingView Strategy Development

Look, if you want the fastest way to test a trading idea, Pine Script is it. No compilers, no broker APIs, no setting up MetaTrader terminals — just open TradingView, write some code, and you've got a backtest running in seconds. We've used it for years as our first stop before porting anything serious to MQL4 or Python. Incredible prototyping tool. But a production trading system? We need to talk about that.

This piece walks through building real strategies in Pine Script v5 — the skeleton, the gotchas, and yes, the webhook plumbing you'll need if you actually want this thing placing trades on a live account.

Pine Script Strategy Skeleton

//@version=5
strategy("My Strategy", overlay=true,
         default_qty_type=strategy.percent_of_equity,
         default_qty_value=1,
         initial_capital=10000,
         commission_type=strategy.commission.percent,
         commission_value=0.07,      // 0.07% = ~0.7 pip spread
         slippage=1,
         pyramiding=0)               // 0 = no stacking positions

// === INPUTS ===
fastLen = input.int(21, "Fast EMA", minval=1)
slowLen = input.int(55, "Slow EMA", minval=1)
atrLen  = input.int(14, "ATR Period", minval=1)
atrMult = input.float(1.5, "ATR SL Multiplier", step=0.1)
tpMult  = input.float(2.0, "TP Multiplier (× SL)", step=0.1)
riskPct = input.float(1.0, "Risk %", step=0.5)

// === INDICATORS ===
emaFast = ta.ema(close, fastLen)
emaSlow = ta.ema(close, slowLen)
atrVal  = ta.atr(atrLen)

// === SIGNALS ===
longSignal  = ta.crossover(emaFast, emaSlow)
shortSignal = ta.crossunder(emaFast, emaSlow)

// === POSITION SIZING ===
slDist  = atrVal * atrMult
tpDist  = slDist * tpMult
riskAmt = strategy.equity * riskPct / 100
qty     = riskAmt / slDist

// === ENTRIES ===
if longSignal
    strategy.entry("Long", strategy.long, qty=qty)
    strategy.exit("Long Exit", "Long",
                  stop=close - slDist,
                  limit=close + tpDist)

if shortSignal
    strategy.entry("Short", strategy.short, qty=qty)
    strategy.exit("Short Exit", "Short",
                  stop=close + slDist,
                  limit=close - tpDist)

// === VISUALS ===
plot(emaFast, "Fast EMA", color.green, 2)
plot(emaSlow, "Slow EMA", color.red, 2)
plotshape(longSignal, "Buy", shape.triangleup,
          location.belowbar, color.green, size=size.small)
plotshape(shortSignal, "Sell", shape.triangledown,
          location.abovebar, color.red, size=size.small)

Key Pine Script Concepts

strategy() vs. indicator()

strategy(...)   // Can place trades, shows backtest results
indicator(...)  // Can only plot, no trading/backtesting

strategy() is what you want for backtesting — it simulates trades and gives you a P&L curve. indicator() just plots stuff on the chart, zero trade simulation. Simple rule: testing a trading idea? strategy(). Building a signal overlay for visual reference? indicator(). We see people confuse these constantly and then wonder why they can't see a backtest report.

Entry and Exit Functions

// ENTRY: Opens a position (or reverses if already in opposite direction)
strategy.entry("Long", strategy.long, qty=1)

// EXIT: Closes a specific entry by name
strategy.exit("Exit Long", "Long", stop=sl, limit=tp)

// CLOSE: Immediately closes all or named position
strategy.close("Long")
strategy.close_all()

// ORDER: Places a trade at a specific price (limit/stop)
strategy.order("Long", strategy.long, qty=1, limit=1.1000)

Position Sizing Methods

// Method 1: Fixed percentage of equity
strategy(..., default_qty_type=strategy.percent_of_equity,
              default_qty_value=2)  // 2% of equity per trade

// Method 2: Fixed quantity
strategy(..., default_qty_type=strategy.fixed,
              default_qty_value=100)  // 100 units per trade

// Method 3: Risk-based (manual calculation)
riskPerTrade = strategy.equity * 0.01   // 1% risk
slDistance   = ta.atr(14) * 1.5
qty = riskPerTrade / slDistance
strategy.entry("Long", strategy.long, qty=qty)

Building a Complete Strategy: Donchian Channel Breakout

//@version=5
strategy("Donchian Breakout", overlay=true,
         initial_capital=10000,
         commission_type=strategy.commission.percent,
         commission_value=0.07,
         default_qty_type=strategy.percent_of_equity,
         default_qty_value=1)

// === INPUTS ===
channelLen   = input.int(20, "Channel Period")
atrLen       = input.int(14, "ATR Period")
riskReward   = input.float(2.0, "Risk:Reward")
useTimeFilter = input.bool(true, "Use Session Filter")
sessionStart  = input.int(8, "Session Start Hour")
sessionEnd    = input.int(17, "Session End Hour")
maxTrades     = input.int(3, "Max Trades Per Day")

// === CHANNEL ===
upper = ta.highest(high, channelLen)[1]  // Prior bar's channel (no look-ahead)
lower = ta.lowest(low, channelLen)[1]
mid   = (upper + lower) / 2
atr   = ta.atr(atrLen)

// === FILTERS ===
sessionOk = not useTimeFilter or (hour >= sessionStart and hour <= sessionEnd)

// Daily trade counter
var int todayTrades = 0
newDay = ta.change(time("D")) != 0
if newDay
    todayTrades := 0

tradesOk = todayTrades < maxTrades

// === ENTRY CONDITIONS ===
breakUp   = close > upper and close[1] <= upper[1]
breakDown = close < lower and close[1] >= lower[1]

if breakUp and sessionOk and tradesOk and strategy.position_size == 0
    sl = lower
    risk = close - sl
    tp = close + risk * riskReward
    strategy.entry("Long", strategy.long)
    strategy.exit("Long SL/TP", "Long", stop=sl, limit=tp)
    todayTrades += 1

if breakDown and sessionOk and tradesOk and strategy.position_size == 0
    sl = upper
    risk = sl - close
    tp = close - risk * riskReward
    strategy.entry("Short", strategy.short)
    strategy.exit("Short SL/TP", "Short", stop=sl, limit=tp)
    todayTrades += 1

// === TRAILING STOP ===
if strategy.position_size > 0
    trailStop = close - atr * 2
    strategy.exit("Trail Long", "Long", stop=trailStop)
if strategy.position_size < 0
    trailStop = close + atr * 2
    strategy.exit("Trail Short", "Short", stop=trailStop)

// === VISUALS ===
p1 = plot(upper, "Upper", color.green)
p2 = plot(lower, "Lower", color.red)
plot(mid, "Mid", color.gray, style=plot.style_circles)
fill(p1, p2, color=color.new(color.blue, 92))

bgcolor(sessionOk ? na : color.new(color.red, 95))

From Strategy to Live Trading: Webhook Automation

Here's the thing that trips up most beginners — and the thing that makes us cringe when we see people selling Pine Script "bots" for $500 on Twitter. Pine Script cannot execute trades on a broker. Full stop. What it can do is fire alerts, and those alerts can hit a webhook endpoint that your code translates into actual orders.

The chain is: Pine Script → TradingView Alert → Webhook → Broker API. Every single link in that chain is a potential failure point. Miss an alert because TradingView's servers hiccuped? Too bad. Webhook endpoint goes down? Missed trade. This is why we prototype in Pine but run production systems elsewhere.

Step 1: Add Alert Conditions

// At the bottom of your strategy:
alertcondition(breakUp and sessionOk, "Long Entry",
    '{"action": "buy", "symbol": "{{ticker}}", "price": {{close}}}')

alertcondition(breakDown and sessionOk, "Short Entry",
    '{"action": "sell", "symbol": "{{ticker}}", "price": {{close}}}')

Step 2: Set Up TradingView Alert

  1. Right-click chart → Add Alert
  2. Condition: select your strategy
  3. Alert actions: check "Webhook URL" (Pro plan or higher required)
  4. Enter your webhook endpoint (e.g., https://your-server.com/webhook)
  5. Message: use the JSON format from the alertcondition

Step 3: Webhook Server (Node.js Example)

const express = require('express');
const ccxt = require('ccxt');

const app = express();
app.use(express.json());

const exchange = new ccxt.binance({
    apiKey: process.env.API_KEY,
    secret: process.env.API_SECRET,
});

app.post('/webhook', async (req, res) => {
    const { action, symbol, price } = req.body;

    try {
        if (action === 'buy') {
            await exchange.createMarketBuyOrder(symbol, 0.01);
            console.log(`BUY ${symbol} @ ${price}`);
        } else if (action === 'sell') {
            await exchange.createMarketSellOrder(symbol, 0.01);
            console.log(`SELL ${symbol} @ ${price}`);
        }
        res.status(200).json({ ok: true });
    } catch (err) {
        console.error(err.message);
        res.status(500).json({ error: err.message });
    }
});

app.listen(3000);

Pine Script Tips and Gotchas

1. The Repainting Problem

// WRONG: Using close of current bar (changes until bar closes)
if close > upper
    strategy.entry("Long", strategy.long)

// CORRECT: Reference the CLOSED bar
if close[1] > upper[1]
    strategy.entry("Long", strategy.long)

This one has burned more traders than we can count. If you're referencing the current bar's close in your condition, that value is changing in real-time until the bar finalizes. Your signal fires, you get an alert, and then the bar closes differently — poof, signal gone. Classic repaint. Always reference [1] (the previous, confirmed bar) unless you genuinely know what you're doing with tick-level logic.

2. request.security() for Multi-Timeframe

// Get daily EMA on any chart timeframe
dailyEMA = request.security(syminfo.tickerid, "D", ta.ema(close, 50))

// IMPORTANT: use barmerge.lookahead_off to prevent look-ahead bias
dailyEMA = request.security(syminfo.tickerid, "D",
    ta.ema(close, 50), barmerge.gaps_off, barmerge.lookahead_off)

3. var for Persistent Variables

// WITHOUT var: resets every bar
int counter = 0     // Always 0

// WITH var: persists across bars
var int counter = 0  // Accumulates
if longSignal
    counter += 1

4. Historical vs. Real-Time Behavior

This is the silent killer of Pine Script strategies. The engine processes all historical bars first using their final close values — nice, clean, deterministic. Then it switches to real-time mode where prices are streaming and updating mid-bar. A strategy that looked bulletproof in backtesting can start misfiring live because the conditions that triggered on historical bars' final closes now trigger on fluctuating mid-bar prices.

Non-negotiable for live execution — wrap your entries with barstate.isconfirmed:

if longSignal and barstate.isconfirmed
    strategy.entry("Long", strategy.long)

Performance Settings Checklist

SettingFor BacktestingFor Live Alerts
calc_on_every_tickfalse (faster)true (responsive)
process_orders_on_closetrue (realistic)false (default)
commission_valueYour broker's actual costN/A
slippage1-3 pointsN/A
initial_capitalYour real account sizeN/A
pyramiding0 unless intended0

Pine Script is our favorite prototyping tool. Hands down. Twelve years in this game and nothing beats the speed of going from idea to backtest result. But let us be direct with you: a backtested Pine Script strategy is not a production trading system. The gap between "my backtest looks great" and "this reliably executes live trades" involves webhook infrastructure, error handling, retry logic, position reconciliation, and about a dozen other things Pine Script was never designed for. Know the tool's limits. Use it for what it's brilliant at — rapid validation — and build proper infrastructure around it. At Quantumcona, we build the whole pipeline — Pine Script strategy development through to live webhook execution.

Build My Pine Script Strategy →

This is Article 16 of 20 in the Algo Trading Masterclass by Quantumcona.

1of8