Guide

Pine Script Session and Time Filters Without Bugs

Session strings are evaluated in the exchange’s timezone and number days from Sunday. Both defaults are reasonable and both are the opposite of what most people assume.

12 min read

A time filter is the most deceptively simple thing in Pine Script. The code is three lines, it compiles first time, and it is wrong in a way that produces plausible results: trades an hour off, or on the wrong days, or a filter that silently does nothing on a daily chart. The two causes are almost always the same. Sessions run in exchange time rather than yours, and Pine numbers the days of the week starting at Sunday.

Short answer

Build the filter with time(timeframe.period, sessionString, timezone), which returns the bar’s timestamp when the bar falls inside the session and na when it does not. Test it with not na(...) to get a boolean, then combine that boolean with your own conditions. The session string format is "HHMM-HHMM:days" where the day digits run 1 for Sunday through 7 for Saturday, and the string is interpreted in the exchange’s timezone unless you pass a different one.

Key facts

  • time() returns the bar’s opening timestamp when the bar is inside the session and na when it is not, so the filter is a test for na rather than a comparison.
  • A session string is "HHMM-HHMM" with an optional ":days" segment, for example 0930-1600:23456 for US regular hours on weekdays.
  • Pine numbers days 1 through 7 starting at Sunday, so Monday is 2 and Friday is 6. This is off by one from most other systems.
  • Session strings are interpreted in the exchange timezone by default, which syminfo.timezone reports for the current symbol.
  • When the end time is earlier than or equal to the start time, the session runs through midnight into the next day.
  • input.session() gives users a session picker in the settings dialog, so the window can be changed without editing and recompiling the script.
  • A session filter has no useful effect on daily or higher timeframes, because one bar already covers the whole session.
  • Daylight saving transitions happen on different dates in different regions, so a hardcoded offset between two markets is wrong for part of every year.
ApproachWhat it filters onSurvives a symbol changeBest for
time() with a session stringExchange trading hoursYes, with syminfo.timezoneIntraday hour-of-day rules
input.session()Same, user adjustableYesAnything shipped to other people
hour and minute variablesExchange clock timeYes, but manualSimple single-threshold checks
dayofweekDay of week onlyYesExcluding specific weekdays
timestamp() comparisonAbsolute date rangeYesLimiting a backtest to a period

The mechanism, and why it looks strange

The core function does not look like a filter, which is why the pattern is worth learning as a shape rather than deriving each time. time(timeframe.period, session, timezone) is asked whether the current bar falls inside the session you described. When it does, it returns the bar’s opening timestamp, a large number. When it does not, it returns na. So the question "am I in the session?" becomes the question "did that call return something?", which is what not na(...) answers.

This is worth understanding rather than memorising, because it explains a mistake people make when they try to shortcut it. Comparing the result of time() to something, or using it as a boolean directly, does not work: a timestamp is not a truth value, and na does not behave as false in the way you might hope, particularly under the stricter boolean rules in Pine v6. The na test is not ceremony, it is the actual conversion.

Once you have the boolean, the derived signals come almost free. A bar where the session is active and the previous bar’s was not is the session opening. The reverse is the session closing. Those two are frequently what an intraday rule genuinely wants, because "enter when the session opens" is a different rule from "enter on any bar during the session", and the second one fires far more often than people expect.

pine
//@version=6
indicator("Session filter", overlay = true)

// input.session() gives a session picker in the settings dialog rather
// than burying the window in the source.
sessionInput = input.session("0930-1600:23456", "Trading session")

// syminfo.timezone is the exchange's own zone. Passing it explicitly
// matches the default and documents the decision.
inSessionTime = time(timeframe.period, sessionInput, syminfo.timezone)
inSession     = not na(inSessionTime)

// The two transitions, which is what most intraday rules actually want.
sessionOpened = inSession and not inSession[1]
sessionClosed = not inSession and inSession[1]

bgcolor(inSession ? color.new(color.teal, 92) : na, title = "In session")
plotshape(sessionOpened, "Open",  style = shape.triangleup,   location = location.bottom)
plotshape(sessionClosed, "Close", style = shape.triangledown, location = location.top)

The bgcolor line is the part to keep while developing. Shading the session on the chart turns "is my filter right?" from a reasoning problem into a looking problem, and a filter that is an hour out is immediately obvious against the actual price action rather than requiring you to reconcile timestamps.

The timezone default is correct and surprising

Session strings are interpreted in the exchange’s timezone, not in yours and not in UTC. This is the right default, because market hours are a property of the market. The New York open is half past nine in New York regardless of who is looking, and a filter that moved with the viewer would be useless for describing market behaviour.

It surprises people because the chart shows times in whatever zone their TradingView settings specify, so the numbers on the axis and the numbers in the session string can refer to different clocks. Someone in London writing 0930-1600 for a US equity is describing the correct session and will see it shaded from half past two in the afternoon on their chart. Nothing is wrong. The filter and the axis are simply speaking different languages, and the filter is speaking the more useful one.

The real trap is not the offset itself but assuming it is constant. Regions change their clocks on different dates, so for a couple of weeks each spring and autumn the gap between London and New York is an hour different from the rest of the year. Any code that hardcodes an offset, or that converts by adding a fixed number of hours, is wrong during those windows. Writing the filter against exchange time avoids the problem entirely rather than handling it, which is the better kind of fix.

There are legitimate reasons to pass a specific zone instead of syminfo.timezone. If your rule is genuinely about a particular market’s clock regardless of what is on the chart, for instance filtering a currency pair by the London session, then naming Europe/London is exactly right. The distinction is whether the zone is a property of the symbol or a property of the rule. Use syminfo.timezone for the first and an explicit identifier for the second.

Day numbering starts at Sunday

The day segment of a session string is a run of digits, and those digits are 1 for Sunday through 7 for Saturday. Weekdays are therefore 23456, not 12345. This trips up nearly everyone at least once, and the reason it goes unnoticed is that the resulting bug is quiet: writing 12345 gives you Sunday through Thursday, which on a market that does not trade Sunday behaves like Monday through Thursday. You have silently excluded Friday, and unless you check, the strategy just has fewer trades than it should.

The same numbering applies to the dayofweek variable and the dayofweek.* constants, and the constants are the safer route. Writing dayofweek == dayofweek.friday is unambiguous and self-checking in a way that a digit in a string is not. When you need day filtering separate from hours, prefer the named constants; when it is part of a session string you have no choice, so count carefully and write a comment.

One practical check that costs nothing: shade the session with bgcolor and look at a week of bars. If Friday is unshaded, you have found the off-by-one immediately. This is faster and more reliable than recounting digits, because it tests the thing you care about rather than your arithmetic.

pine
//@version=6
strategy("Session and day filters", overlay = true)

sessionInput = input.session("0930-1600:23456", "Trading session")
inSession    = not na(time(timeframe.period, sessionInput, syminfo.timezone))

// Named constants are clearer than digits when a rule is about days.
// This still uses Pine's numbering underneath, but you cannot miscount it.
skipMondays = input.bool(false, "Skip Mondays")
dayAllowed  = not (skipMondays and dayofweek == dayofweek.monday)

// A backtest date window, using timestamp() for absolute comparison.
startDate = input.time(timestamp("2024-01-01 00:00 +0000"), "Backtest from")
inWindow  = time >= startDate

longSignal = ta.crossover(ta.sma(close, 10), ta.sma(close, 30))

if longSignal and inSession and dayAllowed and inWindow
    strategy.entry("Long", strategy.long)

// Flatten before the session ends rather than holding overnight.
if not inSession and inSession[1] and strategy.position_size != 0
    strategy.close_all(comment = "session end")

The close_all on the session-close transition is the piece people forget. A filter that only gates entries will happily leave a position open overnight and through the weekend, which for an intraday rule is a different strategy from the one described. If the rule is intraday, the exit needs to be part of the filter, not an afterthought.

Why the filter appears to do nothing

The most common report is that a session filter changes nothing at all. Nine times out of ten the chart is on a daily or higher timeframe. On a daily chart, one bar covers the entire trading day, so it necessarily falls inside any session that overlaps the day at all, and every bar passes the filter. The code is correct and the filter is meaningless, because there is no hour-of-day information left in the data to filter on.

This has a corollary worth stating: a strategy that depends on a session filter cannot be meaningfully validated on daily bars. If you develop on a five-minute chart and then check the results on a daily chart to see if it is robust, you are not testing a more conservative version of the same strategy, you are testing a different one with the filter removed.

The other cause of an apparently inert filter is a session that spans midnight without you realising. When the end time is at or before the start time, Pine treats the session as running into the next day. That is correct and necessary for futures, but it means 1600-0930 does not describe "outside US regular hours on the same day", it describes an overnight window that crosses a date boundary and therefore interacts with the day digits in a way that needs thinking about rather than assuming.

Session filters and repainting

A session filter is one of the safer things you can write with respect to repainting, because a bar’s membership in a session is determined by its opening timestamp, which does not change as the bar develops. Unlike a condition based on the close, the filter’s answer on the current bar is final from the moment the bar opens.

That said, it is easy to combine a stable filter with an unstable condition and inherit the instability. If your entry rule is a crossover evaluated on the developing bar, the filter is not what repaints, but the signal still does, and adding a session filter does nothing to fix it. Keeping the two concerns separate in your head is useful: the filter tells you when you are allowed to act, and the signal tells you whether to act, and they can be reliable or unreliable independently.

The session-open transition deserves one specific caution. On the first bar of a session, the previous bar is the last bar of the previous session, which may be many hours earlier or on a previous day. Any indicator that uses the previous bar’s value is therefore comparing across a gap, and for something like a gap-open rule that is exactly what you want, while for a momentum calculation it may be misleading. Neither is a bug, but the distinction matters for what the number means.

Verifying a time filter

Shade the session. Everything else is secondary. bgcolor with a high transparency over the in-session bars, checked against a week of intraday data, catches an hour offset, a day offset, and a midnight-spanning surprise in one glance. Do this before writing any rule that depends on the filter, so you are not debugging two things at once.

Then print the boundaries with log.info() on the transition bars. Logging the exchange time and the timestamp at each session open gives you a list you can check against the market’s published hours, and it is the only way to be certain about the daylight saving weeks without waiting for them to arrive. A filter that is right for fifty weeks a year is still a filter that will produce two weeks of results nobody can explain.

Finally, test on more than one symbol. A filter written with syminfo.timezone should behave sensibly on an instrument in another region, and a filter that hardcodes a zone should behave the same way everywhere by design. Loading the script onto a symbol from a different exchange takes seconds and immediately reveals which of those two you actually wrote.

Where a Pine-focused workflow helps

Time filters are a case where a general-purpose chat assistant tends to produce code that runs and is subtly wrong. The two specific failures show up repeatedly: day digits written as 12345 on the reasonable-sounding assumption that Monday is 1, and a manual timezone conversion by adding hours, which is correct outside the daylight saving transition weeks and wrong inside them. Neither produces an error, so neither gets caught by compiling.

PineScripter is the product we build, and the relevant advantage here is that it works against the retrieved Pine Script manual rather than pattern-matching on plausible-looking code, so the day numbering and the timezone argument are documented facts it can reference. Its edits arrive as a line-level diff, which suits this problem because the fix is usually one string or one argument rather than a new script.

The verification stays with you and it is genuinely easy in this case. Shade the session, look at a week of bars, and check Friday is included. That takes less time than reading a generated explanation, and unlike an explanation it cannot be confidently wrong.

Describe the trading window in plain English, then shade it on the chart to check it

Frequently asked questions

How do I filter trades by time of day in Pine Script?

Call time(timeframe.period, sessionString, syminfo.timezone) and test the result with not na(). The call returns the bar’s timestamp when the bar falls inside the session and na when it does not, so the na test gives you a boolean you can combine with your entry condition using and.

What is the Pine Script session string format?

Two times in 24-hour form without colons, separated by a hyphen, then an optional colon and a run of day digits. US regular hours on weekdays is 0930-1600:23456. Omitting the day segment means every day, and setting the end time at or before the start time means the session runs through midnight into the next day.

Why are Pine Script days numbered from Sunday?

That is simply the convention Pine uses: 1 is Sunday through 7 is Saturday, so weekdays are 23456. Writing 12345 gives you Sunday through Thursday and silently excludes Friday. The dayofweek.* named constants avoid the miscounting when a rule is about days rather than hours.

What timezone do Pine Script sessions use?

The exchange’s timezone by default, which syminfo.timezone reports for the current symbol. It is not your local timezone and not the timezone your chart displays. Pass syminfo.timezone explicitly to document the choice, or name a specific IANA identifier such as Europe/London when the rule is about a particular market’s clock regardless of the symbol.

Why does my session filter do nothing?

Almost always because the chart is on a daily or higher timeframe, where a single bar covers the whole session and therefore always passes the filter. Session filters only do useful work on intraday timeframes. The other possibility is a session whose end time is at or before its start time, which spans midnight rather than describing a same-day window.

How do I close positions at the end of the session?

Detect the closing transition, meaning a bar where the session is not active and the previous bar’s was, then call strategy.close_all() there. A filter that only gates entries will hold positions overnight and through weekends, which turns an intraday rule into something else entirely.

The practical takeaway

A time filter is three lines of code and two conventions worth learning properly: sessions run in exchange time, and days count from Sunday. Build the string with a picker rather than a literal, pass syminfo.timezone so the decision is visible, shade the session while you develop, and remember that an intraday rule needs an exit at the session close as well as a gate on entries.

Because the fix for a broken time filter is usually one string or one argument, PineScripter is our product and edits those lines in place rather than returning a new script. It cannot tell you which hours to trade, and shading the session on your chart is still the check that settles it.

Sources

Related reading: the session and timezone builder, stop loss and take profit in Pine Script, repainting explained, multi-timeframe without repainting, how the execution model works.

Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.