
Pine Script strategies fail silently. Your entry conditions might never trigger, your exit logic might fire on the wrong bar, your position sizing might calculate an impossible quantity, and the strategy will simply sit there producing a flat equity curve with no explanation. Without proper debugging tools, finding these problems means staring at code, adding temporary plot() hacks, or manually calculating values bar by bar.
Pine Script v6 changes this with runtime logging1. The log.info(), log.warning(), and log.error() functions send structured messages to the Pine Logs pane, giving you a detailed execution trace without cluttering your chart2. This guide focuses specifically on debugging strategies, walking through the most common bugs and showing you exactly how to find and fix them with logging.
Before diving into logging techniques, it helps to know what you are looking for. These are the bugs that waste the most time for Pine Script strategy developers.
All of these bugs have one thing in common: they are invisible on the chart. The strategy does not crash. It does not throw an error. It just does not do what you expect. Logging makes the invisible visible.
The three logging functions share the same syntax. The first argument is a formatting string with placeholders in curly braces. Subsequent arguments provide the values to insert into those placeholders3. The formatting syntax matches str.format().
//@version=6
strategy("Debug Demo", overlay = true, process_orders_on_close = true)
// Basic logging syntax:
// log.info("Message with {0} and {1}", value0, value1)
// log.warning("Warning about {0}", someValue)
// log.error("Critical: {0}", errorCondition)
// Each placeholder {N} is replaced by the Nth value argument (zero-indexed).
log.info("Bar {0}: Open={1}, High={2}, Low={3}, Close={4}",
bar_index, open, high, low, close)
This simple example logs the OHLC values for every bar. While you would never leave this in a production script, it demonstrates the basic pattern. The Pine Logs pane will show one entry per bar with the formatted message, and clicking any entry navigates the chart to that bar4.
Raw float values can produce long decimal strings that are hard to read. Use format specifiers inside the placeholders to control precision.
float rsiVal = ta.rsi(close, 14)
float atrVal = ta.atr(14)
// Without formatting: "RSI: 54.28571428571429"
log.info("RSI: {0}", rsiVal)
// With formatting: "RSI: 54.29"
log.info("RSI: {0,number,#.##}", rsiVal)
// Price formatting: "ATR: 2.3500"
log.info("ATR: {0,number,#.####}", atrVal)
The number format pattern uses # for optional digits and 0 for required digits. The pattern #.## displays up to two decimal places, dropping trailing zeros. The pattern #.0000 always shows exactly four decimal places3. Choose the format that matches the precision relevant to your debugging context.
The most common strategy bug is an entry condition that never triggers. The fix starts with logging each component of the condition separately to find which part evaluates to false or na.
//@version=6
strategy("Entry Debug", overlay = true)
int fastLen = input.int(9, "Fast EMA")
int slowLen = input.int(21, "Slow EMA")
float emaFast = ta.ema(close, fastLen)
float emaSlow = ta.ema(close, slowLen)
bool crossUp = ta.crossover(emaFast, emaSlow)
bool noPosition = strategy.opentrades == 0
// Log the state of each component on every confirmed bar
if barstate.isconfirmed
log.info("Bar {0} | emaFast={1,number,#.##} emaSlow={2,number,#.##} crossUp={3} noPosition={4}",
bar_index, emaFast, emaSlow, crossUp, noPosition)
// Entry logic
bool entryCondition = crossUp and noPosition
if entryCondition
log.info(">>> ENTRY TRIGGERED on bar {0} at price {1}", bar_index, close)
strategy.entry("Long", strategy.long)
With this logging in place, you can open the Pine Logs pane and scan through the messages. If crossUp is always false, the EMA values might be too close together or the fast period might be longer than the slow period. If noPosition is always false, a previous position never closed. If both are occasionally true but never on the same bar, you have a timing issue.
The entry trigger log is separated from the component log so you can filter by message text. When debugging, you can quickly scan for "ENTRY TRIGGERED" to see how many entries occurred and on which bars.
Many entry conditions fail because an intermediate calculation returns na, and any comparison involving na evaluates to false4. Use log.warning() to flag na values before they propagate.
float customMA = ta.sma(close, 200)
if barstate.isconfirmed
if na(customMA)
log.warning("customMA is na on bar {0}. Need {1} bars of history.", bar_index, 200)
if na(emaFast)
log.warning("emaFast is na on bar {0}.", bar_index)
A 200-period SMA requires 200 bars of data before it produces a value. If your strategy uses this SMA in an entry condition, no entries will occur during the first 200 bars. This is expected behavior, but logging makes it explicit rather than leaving you to guess why the first 200 bars have no trades.
Once entries are working, the next area to debug is position management. Log the position state after each entry and exit to confirm the strategy is behaving as expected.
// Log after every entry fill
if strategy.opentrades > 0 and strategy.opentrades != strategy.opentrades[1]
log.info("POSITION OPENED | Size: {0} | Avg Price: {1,number,#.####} | Bar: {2}",
strategy.position_size, strategy.position_avg_price, bar_index)
// Log after every exit fill
if strategy.closedtrades > strategy.closedtrades[1]
int lastTrade = strategy.closedtrades - 1
float entryPrice = strategy.closedtrades.entry_price(lastTrade)
float exitPrice = strategy.closedtrades.exit_price(lastTrade)
float profit = strategy.closedtrades.profit(lastTrade)
log.info("POSITION CLOSED | Entry: {0,number,#.####} | Exit: {1,number,#.####} | P&L: {2,number,#.##}",
entryPrice, exitPrice, profit)
The first block detects new position opens by comparing the current number of open trades to the previous bar's count. When the count increases, a new position was filled. The second block detects closed trades the same way and logs the entry price, exit price, and profit for the most recently closed trade.
This gives you a trade-by-trade log that you can cross-reference against the Strategy Tester's trade list. If the values do not match, you have identified a discrepancy to investigate further.
A common exit bug is mismatched entry IDs. The strategy.exit() function requires a from_entry argument that matches the string used in strategy.entry()3. If these do not match, the exit order is never placed.
// BUG: "Long" vs "long" - case-sensitive mismatch
strategy.entry("Long", strategy.long)
strategy.exit("Exit", from_entry = "long", profit = 100) // This will NEVER execute
// FIX: Use consistent naming
string ENTRY_ID = "Long"
strategy.entry(ENTRY_ID, strategy.long)
strategy.exit("Exit", from_entry = ENTRY_ID, profit = 100)
// Log to verify
if strategy.opentrades > 0
log.info("Open trade entry name: {0}", strategy.opentrades.entry_id(0))
Using a constant string variable for the entry ID eliminates this class of bug entirely. Log the entry ID from strategy.opentrades.entry_id() to confirm what the strategy actually recorded as the position's name3.
Strategies that use external data from request.security() or request.financial() can silently break when the requested data is unavailable. Use log.error() to flag these failures immediately.
// Request data from another symbol
float spyClose = request.security("SPY", timeframe.period, close)
float vixClose = request.security("CBOE:VIX", timeframe.period, close)
if barstate.isconfirmed
if na(spyClose)
log.error("SPY data request returned na on bar {0}. Check symbol availability.", bar_index)
if na(vixClose)
log.error("VIX data request returned na on bar {0}. Check symbol/exchange prefix.", bar_index)
// Guard against using na data in calculations
if not na(spyClose) and not na(vixClose)
float ratio = spyClose / vixClose
log.info("SPY/VIX ratio: {0,number,#.##}", ratio)
When request.security() cannot retrieve data for a given bar, it returns na3. Without logging, this na silently propagates through your calculations and causes conditions to evaluate incorrectly. With log.error(), you get an immediate notification in the Pine Logs pane that points to the exact bar where data was missing.
Division by zero does not crash Pine Script. Instead, it produces na, which can silently break downstream logic4. Log a warning whenever a denominator is zero or na.
float avgVolume = ta.sma(volume, 20)
float relativeVolume = na
if barstate.isconfirmed
if na(avgVolume) or avgVolume == 0
log.warning("Average volume is {0} on bar {1}. Relative volume cannot be calculated.",
na(avgVolume) ? "na" : "zero", bar_index)
else
relativeVolume := volume / avgVolume
Logging every tick on every bar generates thousands of messages and can slow down script execution. Use these patterns to log only the information you need, when you need it.
For most debugging purposes, you only care about the final values at bar close, not the intermediate values during realtime updates. Wrap your logging calls in a barstate.isconfirmed check3.
if barstate.isconfirmed
log.info("Confirmed bar {0}: Close={1}", bar_index, close)
During historical execution, every bar is confirmed, so this logs every bar. During realtime execution, it logs only once per bar when the bar closes, rather than on every tick3. This dramatically reduces the number of log entries on live charts.
Instead of logging state on every bar, log only when something meaningful happens: an entry signal fires, a position opens, a threshold is crossed, or an unusual condition is detected.
// Only log when RSI crosses a threshold
float rsiVal = ta.rsi(close, 14)
if ta.crossover(rsiVal, 70)
log.info("RSI crossed above 70 on bar {0}. Value: {1,number,#.##}", bar_index, rsiVal)
if ta.crossunder(rsiVal, 30)
log.info("RSI crossed below 30 on bar {0}. Value: {1,number,#.##}", bar_index, rsiVal)
If you are debugging a visual issue on a specific chart range, restrict logging to the visible bars using chart.left_visible_bar_time and chart.right_visible_bar_time3.
bool isVisible = time >= chart.left_visible_bar_time and time <= chart.right_visible_bar_time
if isVisible and barstate.isconfirmed
log.info("Visible bar {0}: Close={1}", bar_index, close)
This is especially useful on large datasets where logging every bar would produce tens of thousands of entries. By limiting to visible bars, you focus the log output on the exact range you are investigating.
Let us walk through debugging a real strategy that has a bug. Here is a strategy that should enter long on an EMA crossover and exit with a trailing stop, but it is not producing any trades.
//@version=6
strategy("Buggy Strategy", overlay = true)
float fastMA = ta.ema(close, 9)
float slowMA = ta.ema(close, 21)
float atrVal = ta.atr(14)
// Entry
if ta.crossover(fastMA, slowMA) and close > ta.sma(close, 200)
strategy.entry("long", strategy.long)
// Exit: trailing stop
if strategy.opentrades > 0
float trailPrice = strategy.position_avg_price - atrVal * 2
strategy.exit("trail exit", "Long", stop = trailPrice)
Running this on a chart produces zero trades. Let us add logging to find the problem.
//@version=6
strategy("Buggy Strategy - Debug", overlay = true)
float fastMA = ta.ema(close, 9)
float slowMA = ta.ema(close, 21)
float longMA = ta.sma(close, 200)
float atrVal = ta.atr(14)
// Debug: Check entry components
bool crossCondition = ta.crossover(fastMA, slowMA)
bool trendCondition = close > longMA
bool entryCondition = crossCondition and trendCondition
if barstate.isconfirmed and crossCondition
log.info("Crossover detected on bar {0}. trendCondition={1}, longMA={2,number,#.##}, close={3,number,#.##}",
bar_index, trendCondition, longMA, close)
if barstate.isconfirmed and na(longMA)
if bar_index < 200
log.warning("longMA is na on bar {0}. Waiting for 200 bars of data.", bar_index)
// Entry
if entryCondition
log.info(">>> ENTRY on bar {0} at {1,number,#.####}", bar_index, close)
strategy.entry("long", strategy.long)
// Debug: Check exit
if strategy.opentrades > 0
float trailPrice = strategy.position_avg_price - atrVal * 2
log.info("Open position. Avg price: {0,number,#.####}, Trail stop: {1,number,#.####}",
strategy.position_avg_price, trailPrice)
strategy.exit("trail exit", "Long", stop = trailPrice)
// Debug: Check for exit ID mismatch
if strategy.opentrades > 0
string entryName = strategy.opentrades.entry_id(0)
log.info("Entry ID in system: '{0}'", entryName)
After running the debug version, the Pine Logs pane reveals two critical findings.
Finding 1: The crossover detection logs show that crossCondition is true on several bars, but trendCondition is false on all of them. The 200-period SMA is na for the first 200 bars, and on subsequent bars, the close is below the SMA. This is a legitimate filter doing its job, but if you expected trades in the visible range, you now know why there are none.
Finding 2: If we temporarily remove the trend filter to allow entries, the logs reveal the real bug. The entry uses the ID string "long" (lowercase), but the exit references "Long" (uppercase). This case-sensitive mismatch means the exit order never attaches to the entry. The strategy enters positions but never exits them.
//@version=6
strategy("Fixed Strategy", overlay = true)
string ENTRY_ID = "Long"
float fastMA = ta.ema(close, 9)
float slowMA = ta.ema(close, 21)
float atrVal = ta.atr(14)
// Entry: removed 200 SMA filter or made it conditional
if ta.crossover(fastMA, slowMA)
strategy.entry(ENTRY_ID, strategy.long)
// Exit: uses matching ENTRY_ID constant
if strategy.opentrades > 0
float trailPrice = strategy.position_avg_price - atrVal * 2
strategy.exit("Trail Exit", ENTRY_ID, stop = trailPrice)
The fix is simple: use a constant for the entry ID so both the entry and exit reference the exact same string. The strategy now enters and exits correctly.
When your strategy sends alerts to an external system like TradersPost via webhooks, debugging extends beyond Pine Script5. The strategy might generate the right signals, but the webhook payload might be malformed, or the alert might fire on the wrong conditions.
Use logging to verify what your alert message contains before it is sent. Log the exact same string that goes into your alert() call so you can compare the Pine Logs output against what your webhook receiver reports.
string alertMsg = str.format(
'{"action": "buy", "ticker": "{0}", "price": {1}}',
syminfo.ticker, close
)
if ta.crossover(ta.ema(close, 9), ta.ema(close, 21))
log.info("Alert payload: {0}", alertMsg)
alert(alertMsg, alert.freq_once_per_bar_close)
With TradersPost, you can test this workflow end-to-end. TradersPost accepts TradingView webhook alerts and routes them to your broker5. When something goes wrong, you can compare the log output in Pine Logs against the webhook history in your TradersPost dashboard to pinpoint whether the issue is in your Pine Script logic, the alert configuration, or the order routing.
TradersPost also provides its own execution logs, so you have debugging visibility across the entire chain: Pine Script generates the signal, TradingView sends the webhook, TradersPost processes the order, and the broker fills the trade5. If any link in that chain breaks, you can trace it through the combined logs.
Keep these guidelines in mind to get the most out of Pine Script logging without degrading performance.
Logging is not just for finding bugs. It is a permanent part of your strategy development workflow. A well-instrumented strategy with strategic log.warning() and log.error() calls acts as its own monitoring system, alerting you to problems the moment they occur rather than after they have cost you money.
1 Pine Script Release Notes
2 Pine Script v5 to v6 Migration Guide
3 Pine Script v6 Language Reference
4 Pine Script User Manual
5 TradersPost - Automated Trading Platform