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
- PineTS + Sponsors
+ LuxAlgo   + Sponsor

## 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); + }); +});