diff --git a/.github/images/sponsor.png b/.github/images/sponsor.png
new file mode 100644
index 0000000..d72d318
Binary files /dev/null and b/.github/images/sponsor.png differ
diff --git a/README.md b/README.md
index d1a7322..4fb3e3f 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,9 @@ PineTS enables algorithmic traders, quant developers and platforms to integrate
---
- Sponsored by
-
+ Sponsors
+
+
## What is PineTS?
diff --git a/src/Context.class.ts b/src/Context.class.ts
index 44c3bf2..5c3061f 100644
--- a/src/Context.class.ts
+++ b/src/Context.class.ts
@@ -46,6 +46,7 @@ export class Context {
public cache: any = {};
public taState: any = {}; // State for incremental TA calculations
public isSecondaryContext: boolean = false; // Flag to prevent infinite recursion in request.security
+ public chartTimezone: string | null = null; // Chart display timezone (affects log timestamps only, not computation)
public dataVersion: number = 0; // Incremented when market data changes (streaming mode)
public NA: any = NaN;
@@ -198,12 +199,13 @@ export class Context {
return new Date().getTime();
},
get time_tradingday() {
- //FIXME : this is a temporary solution to get the time_tradingday value,
- //we need to implement a better way to handle realtime states based on provider's data when available
- const currentTime = Series.from(_this.data.openTime).get(0);
- if (isNaN(currentTime)) return NaN;
+ // TradingView returns 00:00 UTC of the trading day the bar belongs to.
+ // For daily+ timeframes on 24/7 markets, this equals the bar's close date
+ // (i.e. the date the bar settles / completes).
+ const closeTime = Series.from(_this.data.closeTime).get(0);
+ if (isNaN(closeTime)) return NaN;
const timezone = _this.pine?.syminfo?.timezone || 'UTC';
- const parts = getDatePartsInTimezone(currentTime, timezone);
+ const parts = getDatePartsInTimezone(closeTime, timezone);
return Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0);
},
get inputs() {
diff --git a/src/PineTS.class.ts b/src/PineTS.class.ts
index e30bd9b..3301c07 100644
--- a/src/PineTS.class.ts
+++ b/src/PineTS.class.ts
@@ -6,6 +6,20 @@ import { Context } from './Context.class';
import { Series } from './Series';
import { Indicator } from './Indicator';
+// ── Timeframe duration utility ──────────────────────────────────────
+//prettier-ignore
+const TIMEFRAME_DURATION_MS: Record = {
+ '1': 60_000, '3': 180_000, '5': 300_000, '15': 900_000, '30': 1_800_000,
+ '60': 3_600_000, '120': 7_200_000, '180': 10_800_000, '240': 14_400_000,
+ '4H': 14_400_000, '1D': 86_400_000, 'D': 86_400_000,
+ '1W': 604_800_000, 'W': 604_800_000,
+ '1M': 30 * 86_400_000, 'M': 30 * 86_400_000,
+};
+function getTimeframeDurationMs(timeframe: string | undefined): number {
+ if (!timeframe) return 86_400_000; // default to 1D when timeframe is unknown
+ return TIMEFRAME_DURATION_MS[timeframe] ?? TIMEFRAME_DURATION_MS[timeframe.toUpperCase()] ?? 86_400_000;
+}
+
/**
* This class is a wrapper for the Pine Script language, it allows to run Pine Script code in a JavaScript environment
*/
@@ -55,6 +69,18 @@ export class PineTS {
}
private _syminfo: ISymbolInfo;
+ private _chartTimezone: string | null = null;
+
+ /**
+ * Set the chart display timezone (like TradingView's timezone picker).
+ * This only affects log timestamp formatting — it does NOT change the timezone
+ * used by computation functions (timestamp(), dayofmonth, hour, etc.), which
+ * always use the exchange timezone from syminfo.timezone.
+ * @param timezone IANA timezone name (e.g. 'America/New_York'), UTC offset ('UTC+5'), or 'UTC'
+ */
+ public setTimezone(timezone: string) {
+ this._chartTimezone = timezone;
+ }
constructor(
private source: IProvider | any[],
@@ -81,7 +107,13 @@ export class PineTS {
const _ohlc4 = marketData.map((d) => (d.high + d.low + d.open + d.close) / 4);
const _hlcc4 = marketData.map((d) => (d.high + d.low + d.close + d.close) / 4);
const _openTime = marketData.map((d) => d.openTime);
- const _closeTime = marketData.map((d) => d.closeTime);
+ // Providers should supply closeTime in TV convention (= next bar open).
+ // Safety-net for array-based data or providers that omit closeTime:
+ // estimate as openTime + timeframe duration.
+ const tfDurationMs = getTimeframeDurationMs(this.timeframe);
+ const _closeTime = marketData.map((d) =>
+ d.closeTime != null ? d.closeTime : d.openTime + tfDurationMs
+ );
this.open = _open;
this.close = _close;
@@ -609,6 +641,12 @@ export class PineTS {
});
context.pine.syminfo = this._syminfo;
+ // Chart timezone only affects display formatting (log timestamps).
+ // It does NOT override syminfo.timezone, which drives computation
+ // (timestamp(), hour, dayofmonth, time_tradingday, etc.).
+ if (this._chartTimezone) {
+ context.chartTimezone = this._chartTimezone;
+ }
context.pineTSCode = pineTSCode;
context.isSecondaryContext = isSecondary; // Set secondary context flag
diff --git a/src/Series.ts b/src/Series.ts
index e3627d6..d63e459 100644
--- a/src/Series.ts
+++ b/src/Series.ts
@@ -1,5 +1,5 @@
export class Series {
- constructor(public data: any[], public offset: number = 0) {}
+ constructor(public data: any[], public offset: number = 0) { }
public get(index: number): any {
const realIndex = this.data.length - 1 - (this.offset + index);
@@ -27,6 +27,7 @@ export class Series {
static from(source: any): Series {
if (source instanceof Series) return source;
if (Array.isArray(source)) return new Series(source);
+ if (source != null && typeof source === 'object' && '__value' in source && source.__value instanceof Series) return source.__value;
return new Series([source]); // Treat scalar as single-element array? Or handle differently?
// Ideally, scalar should be treated as a series where get(0) returns the value, and get(>0) might be undefined or NaN?
// But for now, let's wrap in array.
diff --git a/src/marketData/Binance/BinanceProvider.class.ts b/src/marketData/Binance/BinanceProvider.class.ts
index c50c641..24ca5d9 100644
--- a/src/marketData/Binance/BinanceProvider.class.ts
+++ b/src/marketData/Binance/BinanceProvider.class.ts
@@ -92,6 +92,21 @@ export class BinanceProvider implements IProvider {
this.cacheManager = new CacheManager(5 * 60 * 1000); // 5 minutes cache duration
}
+ /**
+ * Normalize closeTime to TradingView convention: closeTime = next bar's openTime.
+ * Binance raw API returns closeTime as (nextBarOpen - 1ms). For all bars except the
+ * last, we use the next bar's actual openTime (exact). For the last bar, we add 1ms
+ * to the raw value.
+ */
+ private _normalizeCloseTime(data: any[]): void {
+ for (let i = 0; i < data.length - 1; i++) {
+ data[i].closeTime = data[i + 1].openTime;
+ }
+ if (data.length > 0) {
+ data[data.length - 1].closeTime = data[data.length - 1].closeTime + 1;
+ }
+ }
+
/**
* Resolves the working Binance API endpoint.
* Tries default first, then falls back to US endpoint.
@@ -135,6 +150,52 @@ export class BinanceProvider implements IProvider {
return BINANCE_API_URL_DEFAULT;
}
+ /**
+ * Fetch a single chunk of raw kline data from the Binance API (no closeTime normalization).
+ * Used internally by pagination methods that assemble chunks before normalizing.
+ */
+ private async _fetchRawChunk(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise {
+ const interval = timeframe_to_binance[timeframe.toUpperCase()];
+ if (!interval) {
+ console.error(`Unsupported timeframe: ${timeframe}`);
+ return [];
+ }
+
+ const baseUrl = await this.getBaseUrl();
+ let url = `${baseUrl}/klines?symbol=${tickerId}&interval=${interval}`;
+
+ if (limit) {
+ url += `&limit=${Math.min(limit, 1000)}`;
+ }
+ if (sDate) {
+ url += `&startTime=${sDate}`;
+ }
+ if (eDate) {
+ url += `&endTime=${eDate}`;
+ }
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const result = await response.json();
+
+ return result.map((item) => ({
+ openTime: parseInt(item[0]),
+ open: parseFloat(item[1]),
+ high: parseFloat(item[2]),
+ low: parseFloat(item[3]),
+ close: parseFloat(item[4]),
+ volume: parseFloat(item[5]),
+ closeTime: parseInt(item[6]),
+ quoteAssetVolume: parseFloat(item[7]),
+ numberOfTrades: parseInt(item[8]),
+ takerBuyBaseAssetVolume: parseFloat(item[9]),
+ takerBuyQuoteAssetVolume: parseFloat(item[10]),
+ ignore: item[11],
+ }));
+ }
+
async getMarketDataInterval(tickerId: string, timeframe: string, sDate: number, eDate: number): Promise {
try {
const interval = timeframe_to_binance[timeframe.toUpperCase()];
@@ -170,25 +231,18 @@ export class BinanceProvider implements IProvider {
while (currentStart < endTime) {
const chunkEnd = Math.min(currentStart + 1000 * intervalDuration, endTime);
- const data = await this.getMarketData(
- tickerId,
- timeframe,
- 1000, // Max allowed by Binance
- currentStart,
- chunkEnd,
- );
+ const data = await this._fetchRawChunk(tickerId, timeframe, 1000, currentStart, chunkEnd);
if (data.length === 0) break;
allData = allData.concat(data);
- // CORRECTED LINE: Remove *1000 since closeTime is already in milliseconds
+ // Raw closeTime is (nextBarOpen - 1ms), so +1 gives the correct pagination cursor
currentStart = data[data.length - 1].closeTime + 1;
-
- // Keep this safety check to exit when we get less than full page
- //if (data.length < 1000) break;
}
+ // Normalize closeTime on the fully assembled data
+ this._normalizeCloseTime(allData);
return allData;
} catch (error) {
console.error('Error in getMarketDataInterval:', error);
@@ -209,8 +263,8 @@ export class BinanceProvider implements IProvider {
iterations++;
const fetchSize = Math.min(remaining, 1000);
- // Fetch batch
- const data = await this.getMarketData(tickerId, timeframe, fetchSize, undefined, currentEndTime);
+ // Fetch raw batch (no normalization yet)
+ const data = await this._fetchRawChunk(tickerId, timeframe, fetchSize, undefined, currentEndTime);
if (data.length === 0) break;
@@ -219,15 +273,15 @@ export class BinanceProvider implements IProvider {
remaining -= data.length;
// Update end time for next batch to be just before the oldest candle we got
- // data[0] is the oldest candle in the batch
currentEndTime = data[0].openTime - 1;
if (data.length < fetchSize) {
- // We got less than requested, meaning we reached the beginning of available data
break;
}
}
+ // Normalize closeTime on the fully assembled data
+ this._normalizeCloseTime(allData);
return allData;
}
@@ -241,7 +295,6 @@ export class BinanceProvider implements IProvider {
if (shouldCache) {
const cachedData = this.cacheManager.get(cacheParams);
if (cachedData) {
- //console.log('cache hit', tickerId, timeframe, limit, sDate, eDate);
return cachedData;
}
}
@@ -257,63 +310,25 @@ export class BinanceProvider implements IProvider {
if (needsPagination) {
if (sDate && eDate) {
- // Forward pagination: Fetch all data using interval pagination, then apply limit
+ // Forward pagination — already normalized by getMarketDataInterval
const allData = await this.getMarketDataInterval(tickerId, timeframe, sDate, eDate);
const result = limit ? allData.slice(0, limit) : allData;
- // Cache the results with original params
this.cacheManager.set(cacheParams, result);
return result;
} else if (limit && limit > 1000) {
- // Backward pagination: Fetch 'limit' candles backwards from eDate (or now)
+ // Backward pagination — already normalized by getMarketDataBackwards
const result = await this.getMarketDataBackwards(tickerId, timeframe, limit, eDate);
- // Cache the results
this.cacheManager.set(cacheParams, result);
return result;
}
}
- // Single request for <= 1000 candles
- const baseUrl = await this.getBaseUrl();
- let url = `${baseUrl}/klines?symbol=${tickerId}&interval=${interval}`;
-
- //example https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1m&limit=1000
- if (limit) {
- url += `&limit=${Math.min(limit, 1000)}`; // Cap at 1000 for single request
- }
-
- if (sDate) {
- url += `&startTime=${sDate}`;
- }
- if (eDate) {
- url += `&endTime=${eDate}`;
- }
-
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const result = await response.json();
+ // Single chunk — fetch raw, then normalize
+ const data = await this._fetchRawChunk(tickerId, timeframe, limit, sDate, eDate);
+ this._normalizeCloseTime(data);
- const data = result.map((item) => {
- return {
- openTime: parseInt(item[0]),
- open: parseFloat(item[1]),
- high: parseFloat(item[2]),
- low: parseFloat(item[3]),
- close: parseFloat(item[4]),
- volume: parseFloat(item[5]),
- closeTime: parseInt(item[6]),
- quoteAssetVolume: parseFloat(item[7]),
- numberOfTrades: parseInt(item[8]),
- takerBuyBaseAssetVolume: parseFloat(item[9]),
- takerBuyQuoteAssetVolume: parseFloat(item[10]),
- ignore: item[11],
- };
- });
-
- // Cache the results
if (shouldCache) {
this.cacheManager.set(cacheParams, data);
}
diff --git a/src/marketData/IProvider.ts b/src/marketData/IProvider.ts
index 840c070..cf95d7b 100644
--- a/src/marketData/IProvider.ts
+++ b/src/marketData/IProvider.ts
@@ -55,6 +55,18 @@ export type ISymbolInfo = {
target_price_low: number;
target_price_median: number;
};
+/**
+ * Market data provider interface.
+ *
+ * ## closeTime convention
+ * Providers MUST return `closeTime` following the TradingView convention:
+ * `closeTime` = the timestamp of the **start of the next bar** (not the last
+ * millisecond of the current bar). For example, a weekly bar opening on
+ * Monday 2019-01-07T00:00Z should have `closeTime = 2019-01-14T00:00Z`.
+ *
+ * If a provider's raw data uses a different convention (e.g., Binance returns
+ * `nextBarOpen - 1ms`), the provider must normalize before returning.
+ */
export interface IProvider {
getMarketData(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise;
getSymbolInfo(tickerId: string): Promise;
diff --git a/src/marketData/Mock/MockProvider.class.ts b/src/marketData/Mock/MockProvider.class.ts
index bfa891a..5a466ea 100644
--- a/src/marketData/Mock/MockProvider.class.ts
+++ b/src/marketData/Mock/MockProvider.class.ts
@@ -230,6 +230,9 @@ export class MockProvider implements IProvider {
// Filter and limit data
const filteredData = this.filterData(allData, sDate, eDate, limit);
+ // Normalize closeTime to TV convention (nextBar.openTime)
+ this._normalizeCloseTime(filteredData);
+
return filteredData;
} catch (error) {
console.error(`Error in MockProvider.getMarketData:`, error);
@@ -389,6 +392,21 @@ export class MockProvider implements IProvider {
}
}
+ /**
+ * Normalize closeTime to TradingView convention: closeTime = next bar's openTime.
+ * Mock data files contain raw Binance data where closeTime = (nextBarOpen - 1ms).
+ * For all bars except the last, we use the next bar's actual openTime. For the
+ * last bar, we add 1ms to the raw value.
+ */
+ private _normalizeCloseTime(data: Kline[]): void {
+ for (let i = 0; i < data.length - 1; i++) {
+ data[i].closeTime = data[i + 1].openTime;
+ }
+ if (data.length > 0) {
+ data[data.length - 1].closeTime = data[data.length - 1].closeTime + 1;
+ }
+ }
+
/**
* Clears the data cache
*/
diff --git a/src/namespaces/Core.ts b/src/namespaces/Core.ts
index 17de399..c4e4bca 100644
--- a/src/namespaces/Core.ts
+++ b/src/namespaces/Core.ts
@@ -189,8 +189,34 @@ export class Core {
}
// Overload 1: timestamp(dateString)
+ // Parse in exchange timezone (not system local time) to match TradingView behaviour.
if (parsed.dateString !== undefined) {
- return new Date(parsed.dateString).getTime();
+ const ds = parsed.dateString.trim();
+ // If the string already carries explicit timezone info, honour it
+ if (/[Zz]$/.test(ds) || /[+-]\d{2}:?\d{2}$/.test(ds)) {
+ return new Date(ds).getTime();
+ }
+ // Force UTC parse (normalize "YYYY-MM-DD HH:MM" → "YYYY-MM-DDTHH:MMZ")
+ // then extract UTC components and reinterpret in exchange timezone.
+ const isoStr = ds.includes('T') ? ds + 'Z' : ds.replace(/\s+/, 'T') + 'Z';
+ const utcDate = new Date(isoStr);
+ if (!isNaN(utcDate.getTime())) {
+ const timezone = this.context.pine?.syminfo?.timezone || 'UTC';
+ return this._timestampFromComponents(
+ timezone,
+ utcDate.getUTCFullYear(),
+ utcDate.getUTCMonth() + 1,
+ utcDate.getUTCDate(),
+ utcDate.getUTCHours(),
+ utcDate.getUTCMinutes(),
+ utcDate.getUTCSeconds(),
+ );
+ }
+ // Fallback for other formats (RFC 2822, etc.)
+ // RFC 2822 strings always include a timezone offset (e.g. "+0000"),
+ // so they are normally caught by the explicit-TZ check above.
+ // Any remaining string that reaches here is non-standard; parse as-is.
+ return new Date(ds).getTime();
}
return NaN;
@@ -217,7 +243,7 @@ export class Core {
// For plain UTC, return directly
const tzNorm = timezone.trim();
- if (tzNorm === 'UTC' || tzNorm === 'GMT') {
+ if (tzNorm === 'UTC' || tzNorm === 'GMT' || tzNorm === 'Etc/UTC') {
return utcDate.getTime();
}
diff --git a/src/namespaces/Log.ts b/src/namespaces/Log.ts
index 387d11c..e5d56c0 100644
--- a/src/namespaces/Log.ts
+++ b/src/namespaces/Log.ts
@@ -2,15 +2,40 @@
import { Series } from '../Series';
import { Context } from '..';
+import { getDatePartsInTimezone } from './Time';
-function formatWithTimezone(date = new Date(), offset?: number) {
- const _offset = offset ?? -date.getTimezoneOffset();
- const sign = _offset >= 0 ? '+' : '-';
- const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0');
+/**
+ * Compute the UTC offset (in minutes) for a given timestamp in a given timezone.
+ * Returns 0 for UTC/GMT/Etc/UTC, and the correct offset for IANA/offset strings.
+ */
+function getTimezoneOffsetMinutes(timestamp: number, timezone: string): number {
+ const tz = timezone.trim();
+ if (tz === 'UTC' || tz === 'GMT' || tz === 'Etc/UTC') return 0;
- const tz = sign + pad(_offset / 60) + ':' + pad(_offset % 60);
+ // UTC/GMT offset notation: "UTC+5", "GMT-03:30"
+ const offsetMatch = tz.match(/^(?:UTC|GMT)([+-])(\d{1,2})(?::(\d{2}))?$/i);
+ if (offsetMatch) {
+ const sign = offsetMatch[1] === '+' ? 1 : -1;
+ const hours = parseInt(offsetMatch[2], 10);
+ const minutes = parseInt(offsetMatch[3] || '0', 10);
+ return sign * (hours * 60 + minutes);
+ }
+
+ // IANA timezone — compute offset by comparing UTC parts with timezone parts
+ const parts = getDatePartsInTimezone(timestamp, timezone);
+ const tzDate = new Date(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second));
+ return Math.round((tzDate.getTime() - timestamp) / 60000);
+}
- return `[${date.toISOString().slice(0, -1)}${tz}]`;
+function formatWithTimezone(timestamp: number, offsetMinutes: number) {
+ const sign = offsetMinutes >= 0 ? '+' : '-';
+ const pad = (n: number) => String(Math.floor(Math.abs(n))).padStart(2, '0');
+
+ const tz = sign + pad(offsetMinutes / 60) + ':' + pad(offsetMinutes % 60);
+
+ // Build ISO-like string adjusted to the target timezone
+ const adjusted = new Date(timestamp + offsetMinutes * 60000);
+ return `[${adjusted.toISOString().slice(0, -1)}${tz}]`;
}
export class Log {
@@ -23,33 +48,33 @@ export class Log {
param(source: any, index: number = 0, name?: string) {
return Series.from(source).get(index);
}
+
+ private _formatTimestamp(): string {
+ const timestamp = this.context.data['openTime'].data[this.context.idx];
+ // Use chart timezone for display (like TradingView's timezone picker),
+ // falling back to the exchange timezone from syminfo.
+ const timezone = this.context.chartTimezone
+ || this.context.pine?.syminfo?.timezone
+ || 'UTC';
+ const offset = getTimezoneOffsetMinutes(timestamp, timezone);
+ return formatWithTimezone(timestamp, offset);
+ }
+
warning(message: string, ...args: any[]) {
// Suppress log output in secondary contexts (created by request.security)
// to match TradingView behavior — only the main chart context produces logs.
if (this.context.isSecondaryContext) return;
- const _timestamp = this.context.data['openTime'].data[this.context.idx];
- //FIXME : we are forcing UTC for now, we need to handle the timezone properly
- const _time = formatWithTimezone(new Date(_timestamp), 0);
-
- console.warn(`${_time} ${this.logFormat(message, ...args)}`);
+ console.warn(`${this._formatTimestamp()} ${this.logFormat(message, ...args)}`);
}
error(message: string, ...args: any[]) {
if (this.context.isSecondaryContext) return;
- const _timestamp = this.context.data['openTime'].data[this.context.idx];
- //FIXME : we are forcing UTC for now, we need to handle the timezone properly
- const _time = formatWithTimezone(new Date(_timestamp), 0);
-
- console.error(`${_time} ${this.logFormat(message, ...args)}`);
+ console.error(`${this._formatTimestamp()} ${this.logFormat(message, ...args)}`);
}
info(message: string, ...args: any[]) {
if (this.context.isSecondaryContext) return;
- const _timestamp = this.context.data['openTime'].data[this.context.idx];
- //FIXME : we are forcing UTC for now, we need to handle the timezone properly
- const _time = formatWithTimezone(new Date(_timestamp), 0);
-
- console.log(`${_time} ${this.logFormat(message, ...args)}`);
+ console.log(`${this._formatTimestamp()} ${this.logFormat(message, ...args)}`);
}
}
diff --git a/src/namespaces/Time.ts b/src/namespaces/Time.ts
index 15577f5..f6343f8 100644
--- a/src/namespaces/Time.ts
+++ b/src/namespaces/Time.ts
@@ -22,8 +22,8 @@ interface DateParts {
export function getDatePartsInTimezone(timestamp: number, timezone: string): DateParts {
const tzNorm = timezone.trim();
- // Fast path: plain UTC / GMT
- if (tzNorm === 'UTC' || tzNorm === 'GMT') {
+ // Fast path: plain UTC / GMT / Etc/UTC
+ if (tzNorm === 'UTC' || tzNorm === 'GMT' || tzNorm === 'Etc/UTC') {
const d = new Date(timestamp);
return {
year: d.getUTCFullYear(),
diff --git a/src/namespaces/ta/methods/vwap.ts b/src/namespaces/ta/methods/vwap.ts
index ca05f7c..f2415c4 100644
--- a/src/namespaces/ta/methods/vwap.ts
+++ b/src/namespaces/ta/methods/vwap.ts
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { Series } from '../../../Series';
+import { getDatePartsInTimezone } from '../../Time';
/**
* VWAP - Volume Weighted Average Price
@@ -54,9 +55,10 @@ export function vwap(context: any) {
// Get current bar's open time to detect session changes
const currentOpenTime = Series.from(context.data.openTime).get(0);
- // Detect new session (new trading day)
- const currentDate = new Date(currentOpenTime);
- const currentSessionDate = currentDate.toISOString().slice(0, 10); // YYYY-MM-DD
+ // Detect new session (new trading day) using exchange timezone
+ const timezone = context.pine?.syminfo?.timezone || 'UTC';
+ const parts = getDatePartsInTimezone(currentOpenTime, timezone);
+ const currentSessionDate = `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
// Use committed state
let cumulativePV = state.prevCumulativePV;
diff --git a/tests/core/time-components.test.ts b/tests/core/time-components.test.ts
index fddc2ab..1d2b242 100644
--- a/tests/core/time-components.test.ts
+++ b/tests/core/time-components.test.ts
@@ -374,14 +374,15 @@ plot(y, "year")
});
describe('time_tradingday', () => {
- it('returns midnight UTC of trading day', async () => {
+ it('returns midnight UTC of the close date (trading day the bar settles)', async () => {
const { result } = await pineTS.run(($) => {
const { time_tradingday } = $.pine;
let td = time_tradingday;
return { td };
});
- // 2019-01-07 00:00:00 UTC → midnight = 1546819200000
- expect(result.td[0]).toBe(Date.UTC(2019, 0, 7, 0, 0, 0));
+ // Weekly bar opens 2019-01-07, closes (= next bar open) 2019-01-14.
+ // TradingView returns 00:00 UTC of the close date → 2019-01-14
+ expect(result.td[0]).toBe(Date.UTC(2019, 0, 14, 0, 0, 0));
});
it('Pine Script string syntax', async () => {
@@ -393,6 +394,7 @@ plot(td, "td")
`;
const { plots } = await pineTS.run(code);
expect(plots['td']).toBeDefined();
- expect(plots['td'].data[0].value).toBe(Date.UTC(2019, 0, 7, 0, 0, 0));
+ // Weekly bar opens 2019-01-07, closeTime = 2019-01-14 → time_tradingday = midnight 2019-01-14
+ expect(plots['td'].data[0].value).toBe(Date.UTC(2019, 0, 14, 0, 0, 0));
});
});
diff --git a/tests/core/timezone-fixes.test.ts b/tests/core/timezone-fixes.test.ts
new file mode 100644
index 0000000..5268d94
--- /dev/null
+++ b/tests/core/timezone-fixes.test.ts
@@ -0,0 +1,314 @@
+/**
+ * Tests for timezone-related fixes:
+ * - Fix 1: closeTime normalization (provider-level + PineTS safety-net)
+ * - Fix 2: time_tradingday uses closeTime instead of openTime
+ * - Fix 3: setTimezone() is display-only (does not affect computation)
+ */
+import { describe, it, expect } from 'vitest';
+import { PineTS } from '../../src/PineTS.class';
+import { Provider } from '../../src/marketData/Provider.class';
+
+// ── Fix 1: closeTime normalization ──────────────────────────────────────
+
+describe('closeTime normalization — provider-level (MockProvider)', () => {
+ // MockProvider normalizes raw Binance closeTime (nextBarOpen - 1ms) to TV convention (nextBarOpen).
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-03-01').getTime());
+
+ it('time_close matches TV convention (next bar openTime)', async () => {
+ const sourceCode = `
+//@version=6
+indicator("CloseTime Test")
+plot(time, "time")
+plot(time_close, "time_close")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const _time = plots['time']?.data;
+ const _tc = plots['time_close']?.data;
+
+ // For each bar (except last), time_close should equal next bar's time
+ for (let i = 0; i < _time.length - 1; i++) {
+ expect(_tc[i].value).toBe(_time[i + 1].value);
+ }
+ });
+
+ it('time_close is exactly 7 days after time for weekly bars', async () => {
+ const sourceCode = `
+//@version=6
+indicator("CloseTime Delta Test")
+plot(time, "time")
+plot(time_close, "time_close")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const _time = plots['time']?.data;
+ const _tc = plots['time_close']?.data;
+ const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
+
+ // For weekly crypto bars, closeTime - openTime = exactly 1 week
+ for (let i = 0; i < _time.length; i++) {
+ expect(_tc[i].value - _time[i].value).toBe(oneWeekMs);
+ }
+ });
+
+ it('first bar closeTime matches TV value exactly', async () => {
+ const sourceCode = `
+//@version=6
+indicator("CloseTime Exact Test")
+plot(time_close, "time_close")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // First weekly bar: opens 2019-01-07, closes 2019-01-14 00:00 UTC
+ expect(plots['time_close'].data[0].value).toBe(new Date('2019-01-14T00:00:00Z').getTime());
+ });
+});
+
+describe('closeTime normalization — PineTS safety-net (array-based data)', () => {
+ it('computes closeTime from openTime + timeframe when not provided', async () => {
+ // Array-based data without closeTime
+ const arrayData = [
+ { openTime: 1546819200000, open: 3800, high: 3900, low: 3700, close: 3850, volume: 100 },
+ { openTime: 1547424000000, open: 3850, high: 3950, low: 3750, close: 3900, volume: 110 },
+ { openTime: 1548028800000, open: 3900, high: 4000, low: 3800, close: 3950, volume: 120 },
+ ];
+
+ const pineTS = new PineTS(arrayData, 'TEST', 'W');
+ const { result } = await pineTS.run(($) => {
+ const { time_close } = $.pine;
+ let tc = time_close;
+ return { tc };
+ });
+
+ // Safety-net: closeTime = openTime + 1W (604800000ms)
+ const oneWeekMs = 604_800_000;
+ expect(result.tc[0]).toBe(1546819200000 + oneWeekMs);
+ expect(result.tc[1]).toBe(1547424000000 + oneWeekMs);
+ });
+
+ it('uses provider closeTime when available (no override)', async () => {
+ // Array-based data WITH closeTime already set
+ const arrayData = [
+ { openTime: 1546819200000, open: 3800, high: 3900, low: 3700, close: 3850, volume: 100, closeTime: 1547424000000 },
+ { openTime: 1547424000000, open: 3850, high: 3950, low: 3750, close: 3900, volume: 110, closeTime: 1548028800000 },
+ ];
+
+ const pineTS = new PineTS(arrayData, 'TEST', 'W');
+ const { result } = await pineTS.run(($) => {
+ const { time_close } = $.pine;
+ let tc = time_close;
+ return { tc };
+ });
+
+ // Should use provider-supplied closeTime, not compute from timeframe
+ expect(result.tc[0]).toBe(1547424000000);
+ expect(result.tc[1]).toBe(1548028800000);
+ });
+
+ it('defaults to 1D duration when timeframe is undefined', async () => {
+ const arrayData = [
+ { openTime: 1546819200000, open: 100, high: 110, low: 90, close: 105, volume: 50 },
+ ];
+
+ // No timeframe specified
+ const pineTS = new PineTS(arrayData);
+ const { result } = await pineTS.run(($) => {
+ const { time_close } = $.pine;
+ let tc = time_close;
+ return { tc };
+ });
+
+ // Default: 1D = 86400000ms
+ expect(result.tc[0]).toBe(1546819200000 + 86_400_000);
+ });
+});
+
+// ── Fix 2: time_tradingday uses closeTime ──────────────────────────────
+
+describe('time_tradingday — uses close date (TV-compatible)', () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-03-01').getTime());
+
+ it('returns midnight UTC of the close date, not open date', async () => {
+ const sourceCode = `
+//@version=6
+indicator("TradingDay Test")
+plot(time, "time")
+plot(time_tradingday, "td")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const _time = plots['time']?.data;
+ const _td = plots['td']?.data;
+
+ // First bar opens 2019-01-07, closes 2019-01-14
+ // time_tradingday should be midnight of 2019-01-14
+ expect(_td[0].value).toBe(Date.UTC(2019, 0, 14, 0, 0, 0));
+ // NOT the open date
+ expect(_td[0].value).not.toBe(_time[0].value);
+ });
+
+ it('time_tradingday equals midnight of closeTime date for each bar', async () => {
+ const sourceCode = `
+//@version=6
+indicator("TradingDay All Bars")
+plot(time_close, "tc")
+plot(time_tradingday, "td")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const _tc = plots['tc']?.data;
+ const _td = plots['td']?.data;
+
+ for (let i = 0; i < _tc.length; i++) {
+ // closeTime → get date → midnight of that date
+ const closeDate = new Date(_tc[i].value);
+ const expectedTD = Date.UTC(closeDate.getUTCFullYear(), closeDate.getUTCMonth(), closeDate.getUTCDate(), 0, 0, 0);
+ expect(_td[i].value).toBe(expectedTD);
+ }
+ });
+
+ it('matches TV values for multiple weeks (validated against TradingView)', async () => {
+ const sourceCode = `
+//@version=6
+indicator("TD TV Comparison")
+plot(time_tradingday, "td")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const tdData = plots['td']?.data;
+
+ // TV expected values for BTCUSDC Weekly, chart timezone UTC:
+ // Bar opens 2019-01-07 → time_tradingday = 2019-01-14 00:00 UTC = 1547424000000
+ // Bar opens 2019-01-14 → time_tradingday = 2019-01-21 00:00 UTC = 1548028800000
+ // Bar opens 2019-01-21 → time_tradingday = 2019-01-28 00:00 UTC = 1548633600000
+ // Bar opens 2019-01-28 → time_tradingday = 2019-02-04 00:00 UTC = 1549238400000
+ expect(tdData[0].value).toBe(1547424000000);
+ expect(tdData[1].value).toBe(1548028800000);
+ expect(tdData[2].value).toBe(1548633600000);
+ expect(tdData[3].value).toBe(1549238400000);
+ });
+});
+
+// ── Fix 3: setTimezone() is display-only ────────────────────────────────
+
+describe('setTimezone() — display-only behavior (does not change computation)', () => {
+
+ it('hour and dayofmonth are unchanged by setTimezone()', async () => {
+ // Run without setTimezone
+ const pineTSDefault = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+
+ // Run with setTimezone
+ const pineTSWithTZ = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTSWithTZ.setTimezone('America/New_York');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+plot(hour, "hour")
+plot(dayofmonth, "dom")
+`;
+ const { plots: plotsDefault } = await pineTSDefault.run(sourceCode);
+ const { plots: plotsWithTZ } = await pineTSWithTZ.run(sourceCode);
+
+ // All computation values should be identical
+ for (let i = 0; i < plotsDefault['hour'].data.length; i++) {
+ expect(plotsWithTZ['hour'].data[i].value).toBe(plotsDefault['hour'].data[i].value);
+ expect(plotsWithTZ['dom'].data[i].value).toBe(plotsDefault['dom'].data[i].value);
+ }
+ });
+
+ it('timestamp() is unchanged by setTimezone()', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTS.setTimezone('UTC-5');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+ts = timestamp("2019-06-10 00:00")
+plot(ts, "ts")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // Should resolve in exchange timezone (UTC), NOT chart timezone (UTC-5)
+ expect(plots['ts'].data[0].value).toBe(1560124800000); // 2019-06-10 00:00 UTC
+ });
+
+ it('time_tradingday is unchanged by setTimezone()', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTS.setTimezone('UTC+8');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+plot(time_tradingday, "td")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // time_tradingday uses syminfo.timezone (UTC), not chart timezone
+ // First bar opens 2019-01-07, closes 2019-01-14 → TD = midnight Jan 14
+ expect(plots['td'].data[0].value).toBe(Date.UTC(2019, 0, 14, 0, 0, 0));
+ });
+
+ it('weekofyear, month, year unchanged by setTimezone()', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTS.setTimezone('Asia/Tokyo'); // UTC+9
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+plot(weekofyear, "woy")
+plot(month, "month")
+plot(year, "year")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // Bar opens 2019-01-07 00:00 UTC — computation uses exchange TZ (UTC)
+ expect(plots['woy'].data[0].value).toBe(2);
+ expect(plots['month'].data[0].value).toBe(1);
+ expect(plots['year'].data[0].value).toBe(2019);
+ });
+});
+
+// ── closeTime normalization end-to-end (TV values) ──────────────────────
+
+describe('closeTime + time_close — TV-validated values', () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null,
+ new Date('2019-01-01').getTime(), new Date('2019-04-01').getTime());
+
+ it('time, time_close, time_tradingday match TradingView exactly', async () => {
+ const sourceCode = `
+//@version=6
+indicator("TV Comparison")
+plot(time, "time")
+plot(time_close, "tc")
+plot(time_tradingday, "td")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const _time = plots['time']?.data;
+ const _tc = plots['tc']?.data;
+ const _td = plots['td']?.data;
+
+ // TV-validated values (BTCUSDC Weekly, UTC):
+ // Bar 2019-01-07: time=1546819200000, time_close=1547424000000, time_tradingday=1547424000000
+ // Bar 2019-01-14: time=1547424000000, time_close=1548028800000, time_tradingday=1548028800000
+ // Bar 2019-01-21: time=1548028800000, time_close=1548633600000, time_tradingday=1548633600000
+ const expectedBars = [
+ { time: 1546819200000, tc: 1547424000000, td: 1547424000000 },
+ { time: 1547424000000, tc: 1548028800000, td: 1548028800000 },
+ { time: 1548028800000, tc: 1548633600000, td: 1548633600000 },
+ { time: 1548633600000, tc: 1549238400000, td: 1549238400000 },
+ { time: 1549238400000, tc: 1549843200000, td: 1549843200000 },
+ { time: 1549843200000, tc: 1550448000000, td: 1550448000000 },
+ { time: 1550448000000, tc: 1551052800000, td: 1551052800000 },
+ { time: 1551052800000, tc: 1551657600000, td: 1551657600000 },
+ { time: 1551657600000, tc: 1552262400000, td: 1552262400000 },
+ { time: 1552262400000, tc: 1552867200000, td: 1552867200000 },
+ { time: 1552867200000, tc: 1553472000000, td: 1553472000000 },
+ { time: 1553472000000, tc: 1554076800000, td: 1554076800000 },
+ ];
+
+ for (let i = 0; i < expectedBars.length; i++) {
+ expect(_time[i].value).toBe(expectedBars[i].time);
+ expect(_tc[i].value).toBe(expectedBars[i].tc);
+ expect(_td[i].value).toBe(expectedBars[i].td);
+ }
+ });
+});
diff --git a/tests/core/timezone.test.ts b/tests/core/timezone.test.ts
new file mode 100644
index 0000000..1c3aa41
--- /dev/null
+++ b/tests/core/timezone.test.ts
@@ -0,0 +1,197 @@
+import { describe, it, expect } from 'vitest';
+import { PineTS } from '../../src/PineTS.class';
+import { Provider } from '../../src/marketData/Provider.class';
+
+// BTCUSDC Weekly — data range covers 2018-12-10 to 2019-06-30
+// Weekly bars start on Mondays. First bar in range: 2018-12-10 (Mon)
+
+describe('Timezone — timestamp(dateString) in UTC', () => {
+ // TV expected values (chart timezone: UTC, BTCUSDC Weekly):
+ // timestamp("2019-06-10 00:00") = 1560124800000 (UTC midnight)
+ // timestamp(2019, 6, 10, 0, 0, 0) = 1560124800000
+ // timestamp("America/New_York", 2019, 6, 10, 0, 0, 0) = 1560139200000 (EDT midnight = UTC+4h)
+
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-30').getTime());
+
+ it('timestamp(dateString) resolves to UTC when chart timezone is UTC', async () => {
+ const sourceCode = `
+//@version=6
+indicator("Timezone Test")
+ts = timestamp("2019-06-10 00:00")
+plot(ts, "ts")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const tsData = plots['ts']?.data;
+ // All bars should produce the same constant value
+ expect(tsData[0].value).toBe(1560124800000);
+ });
+
+ it('timestamp(year, month, day) matches timestamp(dateString) in UTC', async () => {
+ const sourceCode = `
+//@version=6
+indicator("Timezone Test")
+ts1 = timestamp("2019-06-10 00:00")
+ts2 = timestamp(2019, 6, 10, 0, 0, 0)
+plot(ts1, "ts1")
+plot(ts2, "ts2")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ expect(plots['ts1'].data[0].value).toBe(plots['ts2'].data[0].value);
+ expect(plots['ts1'].data[0].value).toBe(1560124800000);
+ });
+
+ it('timestamp with explicit IANA timezone offsets correctly', async () => {
+ const sourceCode = `
+//@version=6
+indicator("Timezone Test")
+ts = timestamp("America/New_York", 2019, 6, 10, 0, 0, 0)
+plot(ts, "ts")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // Midnight in America/New_York (EDT, UTC-4 in June) = 04:00 UTC
+ expect(plots['ts'].data[0].value).toBe(1560139200000);
+ });
+});
+
+describe('Timezone — time component functions in UTC (validated against TradingView)', () => {
+ // TV data: BTCUSDC Weekly, chart timezone UTC
+ // Bar 2019-01-07: hour=0, dayofmonth=7, dayofweek=2(Mon), month=1, year=2019, weekofyear=2
+ // Bar 2019-02-04: hour=0, dayofmonth=4, dayofweek=2(Mon), month=2, year=2019, weekofyear=6
+ // Bar 2019-03-04: hour=0, dayofmonth=4, dayofweek=2(Mon), month=3, year=2019, weekofyear=10
+
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-04-01').getTime());
+
+ it('hour, dayofmonth, dayofweek, month, year, weekofyear match TradingView', async () => {
+ const sourceCode = `
+//@version=6
+indicator("Time Components Test")
+plot(hour, "hour")
+plot(dayofmonth, "dom")
+plot(dayofweek, "dow")
+plot(month, "month")
+plot(year, "year")
+plot(weekofyear, "woy")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ const _hour = plots['hour']?.data;
+ const _dom = plots['dom']?.data;
+ const _dow = plots['dow']?.data;
+ const _month = plots['month']?.data;
+ const _year = plots['year']?.data;
+ const _woy = plots['woy']?.data;
+
+ const startDate = new Date('2019-01-07').getTime();
+ const endDate = new Date('2019-03-25').getTime();
+
+ let plotdata_str = '';
+ for (let i = 0; i < _hour.length; i++) {
+ const time = _hour[i].time;
+ if (time < startDate || time > endDate) continue;
+ const str_time = new Date(time).toISOString().slice(0, -1) + '-00:00';
+ plotdata_str += `[${str_time}]: ${_hour[i].value} ${_dom[i].value} ${_dow[i].value} ${_month[i].value} ${_year[i].value} ${_woy[i].value}\n`;
+ }
+
+ // Expected from TradingView (BTCUSDC Weekly, UTC timezone)
+ // Format: hour dayofmonth dayofweek month year weekofyear
+ const expected_plot = `[2019-01-07T00:00:00.000-00:00]: 0 7 2 1 2019 2
+[2019-01-14T00:00:00.000-00:00]: 0 14 2 1 2019 3
+[2019-01-21T00:00:00.000-00:00]: 0 21 2 1 2019 4
+[2019-01-28T00:00:00.000-00:00]: 0 28 2 1 2019 5
+[2019-02-04T00:00:00.000-00:00]: 0 4 2 2 2019 6
+[2019-02-11T00:00:00.000-00:00]: 0 11 2 2 2019 7
+[2019-02-18T00:00:00.000-00:00]: 0 18 2 2 2019 8
+[2019-02-25T00:00:00.000-00:00]: 0 25 2 2 2019 9
+[2019-03-04T00:00:00.000-00:00]: 0 4 2 3 2019 10
+[2019-03-11T00:00:00.000-00:00]: 0 11 2 3 2019 11
+[2019-03-18T00:00:00.000-00:00]: 0 18 2 3 2019 12
+[2019-03-25T00:00:00.000-00:00]: 0 25 2 3 2019 13`;
+
+ expect(plotdata_str.trim()).toEqual(expected_plot.trim());
+ });
+});
+
+describe('Timezone — setTimezone() does NOT change computation (display-only)', () => {
+ // TradingView behavior: changing chart timezone on crypto (BTCUSDC) has zero effect
+ // on Pine Script computation functions (hour, dayofmonth, timestamp, etc.).
+ // All functions use syminfo.timezone (= Etc/UTC for crypto).
+ // setTimezone() only changes log timestamp display formatting.
+
+ it('hour and dayofmonth stay in exchange timezone (UTC) when chart TZ is UTC+5', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTS.setTimezone('UTC+5');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+plot(hour, "hour")
+plot(dayofmonth, "dom")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // Bar at 2019-01-07 00:00 UTC — computation uses exchange TZ (UTC), NOT chart TZ
+ expect(plots['hour'].data[0].value).toBe(0);
+ expect(plots['dom'].data[0].value).toBe(7);
+ });
+
+ it('hour and dayofmonth stay in exchange timezone (UTC) when chart TZ is UTC-5', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTS.setTimezone('UTC-5');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+plot(hour, "hour")
+plot(dayofmonth, "dom")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // Computation still uses exchange TZ (UTC), chart TZ is display-only
+ expect(plots['hour'].data[0].value).toBe(0);
+ expect(plots['dom'].data[0].value).toBe(7);
+ });
+
+ it('IANA chart timezone does not affect computation', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-04-01').getTime());
+ pineTS.setTimezone('America/New_York');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+plot(hour, "hour")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // Jan 7: exchange is UTC → hour = 0 regardless of chart TZ
+ expect(plots['hour'].data[0].value).toBe(0);
+ });
+});
+
+describe('Timezone — timestamp(dateString) with non-UTC chart timezone', () => {
+ it('timestamp(dateString) always uses exchange timezone, ignoring chart timezone', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTS.setTimezone('America/New_York');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+ts = timestamp("2019-06-10 00:00")
+plot(ts, "ts")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // "2019-06-10 00:00" resolves in exchange timezone (UTC), NOT chart timezone
+ // = 2019-06-10 00:00 UTC = 1560124800000
+ expect(plots['ts'].data[0].value).toBe(1560124800000);
+ });
+
+ it('timestamp with explicit timezone arg works regardless of chart timezone', async () => {
+ const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime());
+ pineTS.setTimezone('America/New_York');
+
+ const sourceCode = `
+//@version=6
+indicator("TZ Test")
+ts = timestamp("UTC", 2019, 6, 10, 0, 0, 0)
+plot(ts, "ts")
+`;
+ const { plots } = await pineTS.run(sourceCode);
+ // Explicit "UTC" arg always uses UTC
+ expect(plots['ts'].data[0].value).toBe(1560124800000);
+ });
+});