Clock and sessions kit

View as MarkdownOpen the editor

The ./sdk/clock module turns the one number bar.time() gives a wrun indicator, the bar's open time in UTC epoch seconds, into the questions a trader asks of a bar: what hour is it in New York, is this the first bar of the day, is the cash session open, which bar is this, how long is a bar, when does it close.

A Clock answers the calendar questions in a named time zone with its daylight rule applied; a Session answers whether the bar sits inside an 0930-1600 window on the days you list, when a session instance starts and when it has just closed. Both are fed once per bar with update(t) and read as often as you like, and neither allocates per bar. A MarketSession asks the market instead of a zone: it reads the bar's trade date and session phase from the time source (Market sessions, below). A higher timeframe built from the chart's own bars or from a candles stream is the Higher-timeframe kit. The hand-rolled integer math they replace is still on Time and sessions, the manual way, if you want to see what the modules do for you.

Construct both classes in onStart(), never at module start: a bad zone or a bad session spec aborts in the constructor, and an abort raised in onStart() shows its message in the Console, while an abort at module start loses it.

The clock

ExportSignatureAnswers
Clocknew Clock(zone: string = "UTC")a bar clock in one zone; an unknown zone aborts with Clock: unknown zone <x>
updateclock.update(t: f64): voidfolds the bar's open time (bar.time(), UTC epoch seconds); call once per bar
offsetSecclock.offsetSec(): i32the zone's offset from UTC for this bar in seconds, daylight time applied (New York reads -18000 in winter, -14400 in summer)
hour, minute, secondclock.hour(): i32the local time of the bar's open: 0..23, 0..59, 0..59 (second is 0 on every candle grid)
weekdayclock.weekday(): i320 Sunday .. 6 Saturday
dayOfMonth, month, year, dayOfYearclock.month(): i321..31, 1..12, the four-digit year, 1..366
dayKeyclock.dayKey(): i32local days since 1970-01-01: the same number on every bar of one local day
weekKeyclock.weekKey(): i32local weeks since Monday 1970-01-05, a monotonic count (weeks start on Monday)
monthKeyclock.monthKey(): i32year * 12 + (month - 1)
quarterKeyclock.quarterKey(): i32year * 4 + (month - 1) / 3 in integer division: the same number on every bar of one local quarter (the year's own key is year())
isNewDay, isNewWeek, isNewMonth, isNewQuarter, isNewYearclock.isNewDay(): booltrue on the first bar of the period and on the very first update; a quarter opens on January, April, July or October 1st
indexclock.index(): i32updates seen so far, 0-based: 0 on the first bar, -1 before any
intervalSecclock.intervalSec(): f64the smallest positive gap between consecutive bar opens seen so far (a weekend gap never widens it); NaN until two bars
barCloseSecclock.barCloseSec(): f64t + intervalSec(): where this bar closes, NaN while the interval is unknown
dayOfWeekclock.dayOfWeek(): i32Pine's dayofweek: 1 Sunday .. 7 Saturday (weekday() + 1), the scale of the dayofweek.* constants
weekOfYearclock.weekOfYear(): i32Pine's weekofyear, the ISO 8601 week: weeks run Monday to Sunday and belong to the year their Thursday is in, so week 1 holds January 4th, January 1st to 3rd can still be week 52 or 53 of the year before, and a late-December Monday can already be week 1; 0 before any update
epochSecclock.epochSec(year, month, day, hour = 0, minute = 0, second = 0): f64Pine's timestamp(...) in the clock's zone, in seconds: the UTC instant of a wall-clock time with the daylight rule applied; the parts normalise as Pine's do (month 13, day 0, dayOfMonth + 1 past a month end, hour 24, negative parts)
resetclock.reset(): voidback to the freshly constructed state; the zone stays

Two more names come with the clock, for a wall-clock time that needs no zone and for Pine's weekday constants:

ExportSignatureAnswers
epochSecepochSec(year, month, day, hour = 0, minute = 0, second = 0): f64the same instant read in UTC, with no clock: Pine's timestamp("UTC", ...), in seconds, the parts normalised the same way
dayofweekdayofweek.SUNDAY .. dayofweek.SATURDAY (i32, 1..7)Pine's dayofweek.* constants, the scale dayOfWeek() answers on

epochSec answers seconds because every time in a wrun indicator is seconds (bar.time(), a param.time default, a drawing's x); Pine's timestamp() answers milliseconds, so a value a script holds in Pine's unit is this times 1000.0. A wall time inside a daylight gap (02:30 on the morning the clocks go forward) moves forward by the gap, and one in the repeated autumn hour reads as its first, daylight, occurrence: the answers the Java calendar gives TradingView. Neither call allocates, so clock.epochSec(clock.year(), clock.month(), clock.dayOfMonth(), 9, 30) on every bar is fine.

Dates from 1970-01-01 to 2099-12-31 are valid, and the calendar is integer math (days from civil and civil from days, no floating-point days), so every field is exact. A non-finite t does not move the clock: the fields keep their last values, the new-period flags read false, the interval is not fed; only index() advances.

Zones and their daylight rules

ZoneOffset (standard / daylight)Daylight rule
"UTC", "GMT", "Etc/UTC", "Etc/GMT"+00:00none
"America/New_York"-05:00 / -04:00US: from the second Sunday of March 02:00 local standard time to the first Sunday of November 02:00 local daylight time
"America/Toronto"-05:00 / -04:00US
"America/Chicago"-06:00 / -05:00US
"America/Denver"-07:00 / -06:00US
"America/Phoenix"-07:00none
"America/Los_Angeles"-08:00 / -07:00US
"America/Mexico_City"-06:00none since 2023
"America/Bogota"-05:00none
"America/Lima"-05:00none
"America/Sao_Paulo"-03:00none since 2020
"America/Argentina/Buenos_Aires", "America/Buenos_Aires"-03:00none since 2010
"Europe/London"+00:00 / +01:00EU: from the last Sunday of March 01:00 UTC to the last Sunday of October 01:00 UTC
"Europe/Dublin"+00:00 / +01:00EU
"Europe/Lisbon"+00:00 / +01:00EU
"Atlantic/Reykjavik"+00:00none
"Europe/Paris"+01:00 / +02:00EU
"Europe/Brussels"+01:00 / +02:00EU
"Europe/Amsterdam"+01:00 / +02:00EU
"Europe/Berlin"+01:00 / +02:00EU
"Europe/Zurich"+01:00 / +02:00EU
"Europe/Madrid"+01:00 / +02:00EU
"Europe/Rome"+01:00 / +02:00EU
"Europe/Vienna"+01:00 / +02:00EU
"Europe/Stockholm"+01:00 / +02:00EU
"Europe/Oslo"+01:00 / +02:00EU
"Europe/Copenhagen"+01:00 / +02:00EU
"Europe/Prague"+01:00 / +02:00EU
"Europe/Warsaw"+01:00 / +02:00EU
"Europe/Budapest"+01:00 / +02:00EU
"Europe/Luxembourg"+01:00 / +02:00EU
"Europe/Malta"+01:00 / +02:00EU
"Europe/Monaco"+01:00 / +02:00EU
"Europe/Zagreb"+01:00 / +02:00EU
"Europe/Ljubljana"+01:00 / +02:00EU
"Europe/Bratislava"+01:00 / +02:00EU
"Europe/Belgrade"+01:00 / +02:00EU
"Europe/Sarajevo"+01:00 / +02:00EU
"Europe/Podgorica"+01:00 / +02:00EU
"Europe/Skopje"+01:00 / +02:00EU
"Europe/Tirane"+01:00 / +02:00EU
"Europe/Vaduz"+01:00 / +02:00EU
"Europe/Andorra"+01:00 / +02:00EU
"Europe/Gibraltar"+01:00 / +02:00EU
"Europe/San_Marino"+01:00 / +02:00EU
"Europe/Vatican"+01:00 / +02:00EU
"Europe/Helsinki"+02:00 / +03:00EU
"Europe/Athens"+02:00 / +03:00EU
"Europe/Bucharest"+02:00 / +03:00EU
"Europe/Sofia"+02:00 / +03:00EU
"Europe/Riga"+02:00 / +03:00EU
"Europe/Tallinn"+02:00 / +03:00EU
"Europe/Vilnius"+02:00 / +03:00EU
"Europe/Kyiv", "Europe/Kiev"+02:00 / +03:00EU
"Europe/Chisinau"+02:00 / +03:00EU (since 2022; Moldova switched at 00:00 UTC before)
"Asia/Nicosia", "Europe/Nicosia"+02:00 / +03:00EU
"Europe/Istanbul"+03:00none since 2017
"Europe/Moscow"+03:00none since 2015
"Europe/Minsk"+03:00none since 2012
"Africa/Johannesburg"+02:00none
"Africa/Cairo"+02:00 / +03:00Egypt: from the last Friday of April 00:00 local standard time to the last Thursday of October 24:00 local daylight time (since 2023)
"Africa/Lagos"+01:00none
"Africa/Nairobi"+03:00none
"Asia/Jerusalem", "Asia/Tel_Aviv"+02:00 / +03:00Israel: from the Friday before the last Sunday of March 02:00 local standard time to the last Sunday of October 02:00 local daylight time (since 2013)
"Asia/Riyadh"+03:00none
"Asia/Dubai"+04:00none
"Asia/Tehran"+03:30none since 2023
"Asia/Karachi"+05:00none since 2010
"Asia/Kolkata", "Asia/Calcutta"+05:30none
"Asia/Kathmandu", "Asia/Katmandu"+05:45none
"Asia/Dhaka"+06:00none since 2010
"Asia/Yangon", "Asia/Rangoon"+06:30none
"Asia/Bangkok"+07:00none
"Asia/Jakarta"+07:00none
"Asia/Ho_Chi_Minh", "Asia/Saigon"+07:00none
"Asia/Singapore"+08:00none
"Asia/Kuala_Lumpur"+08:00none
"Asia/Manila"+08:00none
"Asia/Hong_Kong"+08:00none
"Asia/Shanghai"+08:00none
"Asia/Taipei"+08:00none
"Asia/Seoul"+09:00none
"Asia/Tokyo"+09:00none
"Australia/Perth"+08:00none since 2010
"Australia/Darwin"+09:30none
"Australia/Adelaide"+09:30 / +10:30AU: from the first Sunday of October 02:00 local standard time to the first Sunday of April 03:00 local daylight time (it spans the new year; since 2008)
"Australia/Brisbane"+10:00none
"Australia/Sydney"+10:00 / +11:00AU (since 2008)
"Australia/Melbourne"+10:00 / +11:00AU (since 2008)
"Australia/Hobart"+10:00 / +11:00AU (since 2008)
"Pacific/Auckland"+12:00 / +13:00NZ: from the last Sunday of September 02:00 local standard time to the first Sunday of April 03:00 local daylight time (it spans the new year; since 2008)
"+HH:MM", "-HH:MM", "+H:MM"as writtennone: a fixed offset written with a colon, such as "+05:30" or "+5:30", hours 00..23 (the bound the first Clock had, so "+15:00" still builds)
"+H", "+HHMM"as writtennone: the same without a colon, such as "-3" or "+0530", at most 14:00 either way so a typo cannot pass as an offset
"UTC+5:30", "GMT-4", "UTC+0530"as writtennone: the same offsets after a UTC or GMT prefix, the way Pine scripts spell them, with the same bounds ("UTC+15:30" builds, "UTC+15" aborts)
"Etc/GMT+N", "Etc/GMT-N"the sign invertednone: the tz database's Etc zones, whole hours up to 14 with the sign inverted ("Etc/GMT+5" is -05:00)

Every year in the valid range follows today's rule for its zone; where a rule changed after 2007 the row says since when (Mexico City kept daylight time until 2022, so a 2019 summer bar reads one hour off here), and a zone's history before that year is not modelled. The table holds every European capital with a zone of its own in the tz database, Reykjavik to Nicosia and Chisinau (Pristina has none: Kosovo keeps Belgrade's time, so "Europe/Belgrade" serves it). Anything else passed as a zone aborts at construction with Clock: unknown zone <x>.

Sessions

ExportSignatureAnswers
Sessionnew Session(spec: string, zone: string = "UTC", days: string = "0123456")an "HHMM-HHMM" window in the zone's local time on the weekdays listed; a spec that is not HHMM-HHMM aborts with Session: bad spec <x>, a day character outside 0..6 with Session: bad days <x>
updatesession.update(t: f64): voidfolds the bar's open time; call once per bar
isInsession.isIn(): boolthe bar's open sits inside the window on a listed day
isFirstsession.isFirst(): boolthis is the first in-session bar of an instance
closedsession.closed(): boolan instance ended before this bar: true on the first update after it, whether this bar is outside or already inside the next instance
keysession.key(): i32the dayKey of the instance's opening day while in, -1 outside
resetsession.reset(): voidforgets the running instance; the spec, zone and days stay

The rules, written out:

  • Half-open. A bar is in when open <= local time of day < close, tested on the bar's open time. "0930-1600" includes the 09:30 bar and excludes the 16:00 bar. open == close is never in. "2400" is accepted as a close, so "0000-2400" is the whole day.
  • Crossing midnight. A close before its open wraps: "2200-0400" runs from 22:00 to 04:00 the next morning. Every bar of that window belongs to the day it opened on, so a Friday-night session keyed to Friday runs into Saturday morning.
  • The day digits. days lists the weekdays the session may OPEN on, as the same digits weekday() uses: 0 Sunday .. 6 Saturday. "12345" is Monday to Friday; a "2200-0400" session on "12345" never opens on Saturday or Sunday night. Pine's session day string counts Sunday as 1, so its "23456" is "12345" here.
  • One instance per opening day. key() is the local dayKey of the opening day; it stays the same across a midnight crossing and changes when the next instance opens. isFirst() marks the first bar of an instance. closed() marks the first update after an instance ended: on a daily chart, where every bar is a new instance, closed() and isFirst() are both true on every bar after the first.

Market sessions

Session is a window you define; MarketSession is the market's own calendar, read from the two market facts the chart fills on every bar: time.trade_date (the exchange trade date, epoch seconds at 00:00 UTC) and time.session (1 regular, 2 pre-market, 3 after-hours, 0 closed). A CME evening bar already belongs to the next trade date, a US stock bar is pre-market, regular or after-hours by the New York clock, and a market without sessions is regular on every bar with the UTC date as its trade date (Data sources).

ExportSignatureAnswers
MarketSessionnew MarketSession()the market's trade date and session phase, bar by bar
updatems.update(tradeDateSec: f64, session: f64): voidfolds time.trade_date and time.session; call once per bar
isRegular, isPremarket, isAfterHoursms.isRegular(): boolthe bar's phase: regular, pre-market, after-hours
isExtendedms.isExtended(): boolpre-market or after-hours
isClosedms.isClosed(): boolthe market is closed on this bar (any session value other than 1, 2, 3)
isNewTradingDayms.isNewTradingDay(): boolthe first bar of a trade date, and the very first update
isFirstRegularBarms.isFirstRegularBar(): boolthe first regular bar of a trade date, after its pre-market bars
tradingDayKeyms.tradingDayKey(): i32the trade date as days since 1970-01-01
tradingWeekKeyms.tradingWeekKey(): i32weeks since Monday 1970-01-05 of the trade date: one number from a Monday trade date through its Sunday
tradingMonthKeyms.tradingMonthKey(): i32year * 12 + (month - 1) of the trade date
resetms.reset(): voidback to the freshly constructed state

The warm-up rule is the Clock's: the very first update is a new trading day, and the first regular bar seen is a first regular bar even when the loaded history starts inside a session. A non-finite trade date or session moves nothing (the phase and keys keep their values, both flags read false); before the first update every check reads false and the keys read 0. The checks map one to one onto Pine's session.* variables (Pine equivalents).

A New York session filter

The close, drawn only while the cash session is open, with a data-only flag for the session and one for its first bar. The chart's bars are in UTC; the session is in New York time, daylight rule included, so the window moves with the clocks in March and November without any change to the file.

output("cash_close", line, overlay, { color: "#2563eb", width: 2, description: "The close while the New York cash session is open, NaN outside" });
output("session_open", none, overlay, { description: "1 from 09:30 to 16:00 New York time on a weekday" });
output("session_start", none, overlay, { description: "1 on the first bar of each cash session" });

let cash = new Session("0930-1600");

function onStart(): void {
  // Constructed here so a bad spec or zone reports in the Console.
  cash = new Session("0930-1600", "America/New_York", "12345");
}

function onBar(): void {
  const close = bar.close();
  cash.update(bar.time());
  const inSession = cash.isIn();
  const first = cash.isFirst();
  out_cash_close(inSession ? close : NaN);
  out_session_open(inSession ? 1.0 : 0.0);
  out_session_start(first ? 1.0 : 0.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

session_open is the gate a mark or an alert reads; session_start is where an anchored VWAP or a session high-low box begins.

A new-day reset of a running sum

Volume accumulated since the New York day started, and since the week started, with the bar's position inside the day. isNewDay() is true on the first bar after local midnight and on the very first bar, so the sums start clean at both; isNewWeek() flips on the first bar of Monday.

output("day_volume", histogram, lower, { color: "#2563eb", description: "Volume since the New York day started" });
output("week_volume", line, lower, { color: "#f59e0b", description: "Volume since the week started (Monday, New York time)" });
output("bars_today", none, lower, { description: "Bars since the day started, 0 on its first bar" });

let clock = new Clock();
let dayVolume: f64 = 0.0;
let weekVolume: f64 = 0.0;
let barsToday: i32 = 0;

function onStart(): void {
  clock = new Clock("America/New_York");
}

function onBar(): void {
  clock.update(bar.time());
  if (clock.isNewDay()) {
    dayVolume = 0.0;
    barsToday = 0;
  } else {
    barsToday += 1;
  }
  if (clock.isNewWeek()) weekVolume = 0.0;
  const volume = bar.volume();
  dayVolume += volume;
  weekVolume += volume;
  if (isNaN(bar.close())) return;
  out_day_volume(dayVolume);
  out_week_volume(weekVolume);
  out_bars_today(f64(barsToday));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The same shape resets on isNewMonth(), isNewQuarter() or isNewYear(), or compares dayKey() with a stored value when the period is yours to define.

An interval-aware bar-close estimate

The module never sees a wall clock, but it sees the gaps between bar opens: intervalSec() is the smallest positive gap so far, so after two bars it is the chart interval, and a weekend gap never stretches it. barCloseSec() adds that interval to the open. A second Clock fed the close time answers what hour the bar closes in, which is how a file knows that the 15:30 bar on a 1-hour chart runs past the cash close.

output("bar_close", none, overlay, { description: "The bar's close time in epoch seconds, NaN until the interval is known" });
output("bar_minutes", none, lower, { description: "The chart interval in minutes, from the smallest gap seen" });
output("close_hour", none, lower, { description: "The New York hour the bar closes in, NaN on the first bar" });

let opens = new Clock();
let closes = new Clock();

function onStart(): void {
  opens = new Clock("America/New_York");
  closes = new Clock("America/New_York");
}

function onBar(): void {
  opens.update(bar.time());
  // NaN on the first bar: a non-finite time does not move a clock.
  closes.update(opens.barCloseSec());
  if (isNaN(bar.close())) return;
  const interval = opens.intervalSec();
  out_bar_close(opens.barCloseSec());
  out_bar_minutes(interval / 60.0);
  out_close_hour(isNaN(interval) ? NaN : f64(closes.hour()));
}

index() on the same clock is the bar index, 0 on the first bar the chart loaded; it counts every update, so it restarts after reset().

Calendar questions

Pine's calendar calls answer on the same clock: a wall-clock time as a timestamp, the quarter, the ISO week and the weekday. The price at the New York cash open held through the day, the open of the running quarter, and two data-only readings:

// The price at the New York cash open, held through the day; the open of the running quarter; the ISO week and a Monday flag.
output("cash_open", line, overlay, { color: "#2563eb", width: 2, description: "The open of the bar that holds 09:30 New York time, held for the rest of the day" });
output("quarter_open", line, overlay, { color: "#94a3b8", line_style: "dashed", description: "The open of the running quarter, New York time" });
output("iso_week", none, lower, { description: "The ISO 8601 week of the bar, 1 to 53" });
output("monday", none, lower, { description: "1 on a Monday in New York, else 0" });

let clock = new Clock();
let cashOpen: f64 = NaN;
let quarterOpen: f64 = NaN;

function onStart(): void {
  clock = new Clock("America/New_York");
}

function onBar(): void {
  const t = bar.time();
  clock.update(t);
  if (clock.isNewDay()) cashOpen = NaN;
  if (clock.isNewQuarter()) quarterOpen = bar.open();
  // Today's 09:30 in New York as UTC epoch seconds, daylight time applied.
  const cashOpenSec = clock.epochSec(clock.year(), clock.month(), clock.dayOfMonth(), 9, 30);
  // The bar that holds 09:30 sets the level. The interval is NaN on the first bar, so nothing is set there.
  if (t <= cashOpenSec && cashOpenSec < t + clock.intervalSec()) cashOpen = bar.open();
  out_cash_open(cashOpen);
  out_quarter_open(quarterOpen);
  out_iso_week(f64(clock.weekOfYear()));
  out_monday(clock.dayOfWeek() == dayofweek.MONDAY ? 1.0 : 0.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine
  • A wall-clock time. clock.epochSec(year, month, day, 9, 30) is that day's 09:30 in New York as UTC epoch seconds: 14:30 UTC in winter, 13:30 UTC in summer. The bar that holds it sets the level, so on a 1-hour chart the line starts at the 09:00 New York bar.
  • The quarter. isNewQuarter() is true on the first bar of January, April, July and October, so the open is taken there. The first quarter on the chart starts at the first loaded bar. quarterKey() holds one number for the whole quarter, for a reset you compare yourself.
  • The week. weekOfYear() is Pine's weekofyear, the ISO week: Monday to Sunday, and week 1 holds January 4th.
  • The weekday. dayOfWeek() counts 1 Sunday to 7 Saturday, as Pine does, so it compares with dayofweek.MONDAY. weekday() counts from 0.

From Pine

One-to-one where the meaning is the same; the zone is the difference, since Pine reads the chart's exchange time and a Clock reads the zone you name.

PineHere
hour, minute, secondclock.hour(), clock.minute(), clock.second()
dayofweek (1 Sunday .. 7 Saturday), dayofweek.mondayclock.dayOfWeek(), dayofweek.MONDAY (clock.weekday() counts the same day from 0)
weekofyearclock.weekOfYear(): the ISO 8601 week, Monday to Sunday (TradingView's own week-of-month example shows a Sunday-evening open still in the week before, and PineTS implements the ISO number)
timestamp(year, month, dayofmonth, 9, 30)clock.epochSec(clock.year(), clock.month(), clock.dayOfMonth(), 9, 30), in seconds (* 1000.0 where a script holds Pine's milliseconds)
timestamp("America/New_York", 2024, 1, 15, 9, 30)ny.epochSec(2024, 1, 15, 9, 30) on a Clock built in that zone
timestamp("UTC", 2024, 1, 15, 9, 30), or timestamp(2024, 1, 15, 9, 30) on a 24/7 venue (its exchange zone is UTC)epochSec(2024, 1, 15, 9, 30); a literal date the user may change is a param.time default
dayofmonth, month, yearclock.dayOfMonth(), clock.month(), clock.year()
timeframe.change("D"), ("W"), ("M"), ("3M"), ("12M")clock.isNewDay(), isNewWeek(), isNewMonth(), isNewQuarter(), isNewYear() in the zone you name; a PeriodLevels built from the same word answers the same with isNew() (Levels kit)
bar_indexclock.index()
not na(time(timeframe.period, "0930-1600:23456"))session.isIn() with new Session("0930-1600", zone, "12345")
time_closeclock.barCloseSec() (in seconds, from the observed interval)
session.ismarket, session.isfirstbar_regular, time_tradingdayMarketSession over time.session and time.trade_date (Market sessions, above)