Skip to content

Commit 731f924

Browse files
authored
Merge pull request #161 from QuantForgeOrg/dev
## [0.9.5] - 2026-03-12 - Time & Timezone Fixes
2 parents a1db0a1 + 6bebf61 commit 731f924

17 files changed

Lines changed: 772 additions & 101 deletions

.github/images/sponsor.png

65.3 KB
Loading

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Change Log
22

3+
## [0.9.5] - 2026-03-12 - Time & Timezone Fixes
4+
5+
### Added
6+
7+
- **`PineTS.setTimezone(timezone)`**: Display-only chart timezone (like TradingView's timezone picker). Accepts IANA names, `UTC±N` offsets, or `'UTC'`. Only affects `log.*` timestamp formatting — computation functions (`timestamp()`, `hour`, `dayofmonth`, `time_tradingday`, etc.) always use the exchange timezone from `syminfo.timezone`.
8+
9+
### Fixed
10+
11+
- **`closeTime` Normalization**: `BinanceProvider` and `MockProvider` now normalize `closeTime` to the TradingView convention (`closeTime = nextBar.openTime`) instead of Binance's raw `nextBarOpen - 1ms`. `IProvider` docs updated to specify this convention. For array-based data missing `closeTime`, `PineTS` now estimates it as `openTime + timeframe duration` (falls back to 1D when unknown).
12+
- **`time_tradingday` Uses Close Date**: Was returning midnight UTC of the bar's open date. Now correctly returns midnight UTC of the **close date** (matching TradingView). E.g. a weekly bar opening `2019-01-07` → closes `2019-01-14``time_tradingday = 2019-01-14 00:00 UTC`.
13+
- **`timestamp(dateString)` Exchange Timezone**: Date strings like `"2019-06-10 00:00"` were parsed in the host system's local timezone. Now explicitly resolved in the exchange timezone (`syminfo.timezone`), matching TradingView behaviour. Strings with explicit offsets or `Z` are honoured as-is.
14+
- **`TimeHelper` as `Series`** ([#156](https://github.com/QuantForgeOrg/PineTS/issues/156)): `Series.from()` now unwraps NAMESPACES_LIKE dual-use objects (`time`, `time_close`, etc.) by detecting the `.__value` Series property, instead of wrapping the object itself. Added a null-guard to prevent a crash when the source is `null`. (contribution by [@dcaoyuan](https://github.com/dcaoyuan))
15+
- **`Log` Timestamps Use Chart Timezone**: `log.info/warning/error` hardcoded UTC for bar timestamp prefixes. They now respect the timezone set via `setTimezone()`, falling back to the exchange timezone.
16+
- **`Etc/UTC` Alias**: Added `'Etc/UTC'` to the fast-path UTC check in `getDatePartsInTimezone()`, fixing date-part calculations for providers that use the canonical `Etc/UTC` identifier (common for crypto).
17+
- **`ta.vwap` Session Timezone**: VWAP day-boundary detection now uses `getDatePartsInTimezone(openTime, syminfo.timezone)` instead of `toISOString().slice(0, 10)`, so session resets are correct for non-UTC exchanges.
18+
19+
---
20+
321
## [0.9.4] - 2026-03-11 - Color Namespace, Transpiler Overhaul, request.security & Drawing Improvements
422

523
### Added

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ PineTS enables algorithmic traders, quant developers and platforms to integrate
2929
---
3030

3131
<p align="center">
32-
<b>Sponsored by</b><br />
33-
<a href="https://luxalgo.com" target="_blank" rel="noopener noreferrer"><img src="./.github/images/luxalgo.png" alt="PineTS" height="80px" /></a>
32+
<b>Sponsors</b><br />
33+
<a href="https://luxalgo.com" target="_blank" rel="noopener noreferrer"><img src="./.github/images/luxalgo.png" alt="LuxAlgo" height="80px" /></a> &nbsp;
34+
<a href="https://github.com/sponsors/QuantForgeOrg" target="_blank" rel="noopener noreferrer"><img src="./.github/images/sponsor.png" alt="Sponsor" height="80px" /></a>
3435
</p>
3536

3637
## What is PineTS?

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pinets",
3-
"version": "0.9.4",
3+
"version": "0.9.5",
44
"description": "Run Pine Script anywhere. PineTS is an open-source transpiler and runtime that brings Pine Script logic to Node.js and the browser with 1:1 syntax compatibility. Reliably write, port, and run indicators or strategies on your own infrastructure.",
55
"keywords": [
66
"Pine Script",

src/Context.class.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class Context {
4646
public cache: any = {};
4747
public taState: any = {}; // State for incremental TA calculations
4848
public isSecondaryContext: boolean = false; // Flag to prevent infinite recursion in request.security
49+
public chartTimezone: string | null = null; // Chart display timezone (affects log timestamps only, not computation)
4950
public dataVersion: number = 0; // Incremented when market data changes (streaming mode)
5051

5152
public NA: any = NaN;
@@ -198,12 +199,13 @@ export class Context {
198199
return new Date().getTime();
199200
},
200201
get time_tradingday() {
201-
//FIXME : this is a temporary solution to get the time_tradingday value,
202-
//we need to implement a better way to handle realtime states based on provider's data when available
203-
const currentTime = Series.from(_this.data.openTime).get(0);
204-
if (isNaN(currentTime)) return NaN;
202+
// TradingView returns 00:00 UTC of the trading day the bar belongs to.
203+
// For daily+ timeframes on 24/7 markets, this equals the bar's close date
204+
// (i.e. the date the bar settles / completes).
205+
const closeTime = Series.from(_this.data.closeTime).get(0);
206+
if (isNaN(closeTime)) return NaN;
205207
const timezone = _this.pine?.syminfo?.timezone || 'UTC';
206-
const parts = getDatePartsInTimezone(currentTime, timezone);
208+
const parts = getDatePartsInTimezone(closeTime, timezone);
207209
return Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0);
208210
},
209211
get inputs() {

src/PineTS.class.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ import { Context } from './Context.class';
66
import { Series } from './Series';
77
import { Indicator } from './Indicator';
88

9+
// ── Timeframe duration utility ──────────────────────────────────────
10+
//prettier-ignore
11+
const TIMEFRAME_DURATION_MS: Record<string, number> = {
12+
'1': 60_000, '3': 180_000, '5': 300_000, '15': 900_000, '30': 1_800_000,
13+
'60': 3_600_000, '120': 7_200_000, '180': 10_800_000, '240': 14_400_000,
14+
'4H': 14_400_000, '1D': 86_400_000, 'D': 86_400_000,
15+
'1W': 604_800_000, 'W': 604_800_000,
16+
'1M': 30 * 86_400_000, 'M': 30 * 86_400_000,
17+
};
18+
function getTimeframeDurationMs(timeframe: string | undefined): number {
19+
if (!timeframe) return 86_400_000; // default to 1D when timeframe is unknown
20+
return TIMEFRAME_DURATION_MS[timeframe] ?? TIMEFRAME_DURATION_MS[timeframe.toUpperCase()] ?? 86_400_000;
21+
}
22+
923
/**
1024
* This class is a wrapper for the Pine Script language, it allows to run Pine Script code in a JavaScript environment
1125
*/
@@ -55,6 +69,18 @@ export class PineTS {
5569
}
5670

5771
private _syminfo: ISymbolInfo;
72+
private _chartTimezone: string | null = null;
73+
74+
/**
75+
* Set the chart display timezone (like TradingView's timezone picker).
76+
* This only affects log timestamp formatting — it does NOT change the timezone
77+
* used by computation functions (timestamp(), dayofmonth, hour, etc.), which
78+
* always use the exchange timezone from syminfo.timezone.
79+
* @param timezone IANA timezone name (e.g. 'America/New_York'), UTC offset ('UTC+5'), or 'UTC'
80+
*/
81+
public setTimezone(timezone: string) {
82+
this._chartTimezone = timezone;
83+
}
5884

5985
constructor(
6086
private source: IProvider | any[],
@@ -81,7 +107,13 @@ export class PineTS {
81107
const _ohlc4 = marketData.map((d) => (d.high + d.low + d.open + d.close) / 4);
82108
const _hlcc4 = marketData.map((d) => (d.high + d.low + d.close + d.close) / 4);
83109
const _openTime = marketData.map((d) => d.openTime);
84-
const _closeTime = marketData.map((d) => d.closeTime);
110+
// Providers should supply closeTime in TV convention (= next bar open).
111+
// Safety-net for array-based data or providers that omit closeTime:
112+
// estimate as openTime + timeframe duration.
113+
const tfDurationMs = getTimeframeDurationMs(this.timeframe);
114+
const _closeTime = marketData.map((d) =>
115+
d.closeTime != null ? d.closeTime : d.openTime + tfDurationMs
116+
);
85117

86118
this.open = _open;
87119
this.close = _close;
@@ -609,6 +641,12 @@ export class PineTS {
609641
});
610642

611643
context.pine.syminfo = this._syminfo;
644+
// Chart timezone only affects display formatting (log timestamps).
645+
// It does NOT override syminfo.timezone, which drives computation
646+
// (timestamp(), hour, dayofmonth, time_tradingday, etc.).
647+
if (this._chartTimezone) {
648+
context.chartTimezone = this._chartTimezone;
649+
}
612650

613651
context.pineTSCode = pineTSCode;
614652
context.isSecondaryContext = isSecondary; // Set secondary context flag

src/Series.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export class Series {
2-
constructor(public data: any[], public offset: number = 0) {}
2+
constructor(public data: any[], public offset: number = 0) { }
33

44
public get(index: number): any {
55
const realIndex = this.data.length - 1 - (this.offset + index);
@@ -27,6 +27,7 @@ export class Series {
2727
static from(source: any): Series {
2828
if (source instanceof Series) return source;
2929
if (Array.isArray(source)) return new Series(source);
30+
if (source != null && typeof source === 'object' && '__value' in source && source.__value instanceof Series) return source.__value;
3031
return new Series([source]); // Treat scalar as single-element array? Or handle differently?
3132
// Ideally, scalar should be treated as a series where get(0) returns the value, and get(>0) might be undefined or NaN?
3233
// But for now, let's wrap in array.

src/marketData/Binance/BinanceProvider.class.ts

Lines changed: 74 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ export class BinanceProvider implements IProvider {
9292
this.cacheManager = new CacheManager(5 * 60 * 1000); // 5 minutes cache duration
9393
}
9494

95+
/**
96+
* Normalize closeTime to TradingView convention: closeTime = next bar's openTime.
97+
* Binance raw API returns closeTime as (nextBarOpen - 1ms). For all bars except the
98+
* last, we use the next bar's actual openTime (exact). For the last bar, we add 1ms
99+
* to the raw value.
100+
*/
101+
private _normalizeCloseTime(data: any[]): void {
102+
for (let i = 0; i < data.length - 1; i++) {
103+
data[i].closeTime = data[i + 1].openTime;
104+
}
105+
if (data.length > 0) {
106+
data[data.length - 1].closeTime = data[data.length - 1].closeTime + 1;
107+
}
108+
}
109+
95110
/**
96111
* Resolves the working Binance API endpoint.
97112
* Tries default first, then falls back to US endpoint.
@@ -135,6 +150,52 @@ export class BinanceProvider implements IProvider {
135150
return BINANCE_API_URL_DEFAULT;
136151
}
137152

153+
/**
154+
* Fetch a single chunk of raw kline data from the Binance API (no closeTime normalization).
155+
* Used internally by pagination methods that assemble chunks before normalizing.
156+
*/
157+
private async _fetchRawChunk(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise<any[]> {
158+
const interval = timeframe_to_binance[timeframe.toUpperCase()];
159+
if (!interval) {
160+
console.error(`Unsupported timeframe: ${timeframe}`);
161+
return [];
162+
}
163+
164+
const baseUrl = await this.getBaseUrl();
165+
let url = `${baseUrl}/klines?symbol=${tickerId}&interval=${interval}`;
166+
167+
if (limit) {
168+
url += `&limit=${Math.min(limit, 1000)}`;
169+
}
170+
if (sDate) {
171+
url += `&startTime=${sDate}`;
172+
}
173+
if (eDate) {
174+
url += `&endTime=${eDate}`;
175+
}
176+
177+
const response = await fetch(url);
178+
if (!response.ok) {
179+
throw new Error(`HTTP error! status: ${response.status}`);
180+
}
181+
const result = await response.json();
182+
183+
return result.map((item) => ({
184+
openTime: parseInt(item[0]),
185+
open: parseFloat(item[1]),
186+
high: parseFloat(item[2]),
187+
low: parseFloat(item[3]),
188+
close: parseFloat(item[4]),
189+
volume: parseFloat(item[5]),
190+
closeTime: parseInt(item[6]),
191+
quoteAssetVolume: parseFloat(item[7]),
192+
numberOfTrades: parseInt(item[8]),
193+
takerBuyBaseAssetVolume: parseFloat(item[9]),
194+
takerBuyQuoteAssetVolume: parseFloat(item[10]),
195+
ignore: item[11],
196+
}));
197+
}
198+
138199
async getMarketDataInterval(tickerId: string, timeframe: string, sDate: number, eDate: number): Promise<any> {
139200
try {
140201
const interval = timeframe_to_binance[timeframe.toUpperCase()];
@@ -170,25 +231,18 @@ export class BinanceProvider implements IProvider {
170231
while (currentStart < endTime) {
171232
const chunkEnd = Math.min(currentStart + 1000 * intervalDuration, endTime);
172233

173-
const data = await this.getMarketData(
174-
tickerId,
175-
timeframe,
176-
1000, // Max allowed by Binance
177-
currentStart,
178-
chunkEnd,
179-
);
234+
const data = await this._fetchRawChunk(tickerId, timeframe, 1000, currentStart, chunkEnd);
180235

181236
if (data.length === 0) break;
182237

183238
allData = allData.concat(data);
184239

185-
// CORRECTED LINE: Remove *1000 since closeTime is already in milliseconds
240+
// Raw closeTime is (nextBarOpen - 1ms), so +1 gives the correct pagination cursor
186241
currentStart = data[data.length - 1].closeTime + 1;
187-
188-
// Keep this safety check to exit when we get less than full page
189-
//if (data.length < 1000) break;
190242
}
191243

244+
// Normalize closeTime on the fully assembled data
245+
this._normalizeCloseTime(allData);
192246
return allData;
193247
} catch (error) {
194248
console.error('Error in getMarketDataInterval:', error);
@@ -209,8 +263,8 @@ export class BinanceProvider implements IProvider {
209263
iterations++;
210264
const fetchSize = Math.min(remaining, 1000);
211265

212-
// Fetch batch
213-
const data = await this.getMarketData(tickerId, timeframe, fetchSize, undefined, currentEndTime);
266+
// Fetch raw batch (no normalization yet)
267+
const data = await this._fetchRawChunk(tickerId, timeframe, fetchSize, undefined, currentEndTime);
214268

215269
if (data.length === 0) break;
216270

@@ -219,15 +273,15 @@ export class BinanceProvider implements IProvider {
219273
remaining -= data.length;
220274

221275
// Update end time for next batch to be just before the oldest candle we got
222-
// data[0] is the oldest candle in the batch
223276
currentEndTime = data[0].openTime - 1;
224277

225278
if (data.length < fetchSize) {
226-
// We got less than requested, meaning we reached the beginning of available data
227279
break;
228280
}
229281
}
230282

283+
// Normalize closeTime on the fully assembled data
284+
this._normalizeCloseTime(allData);
231285
return allData;
232286
}
233287

@@ -241,7 +295,6 @@ export class BinanceProvider implements IProvider {
241295
if (shouldCache) {
242296
const cachedData = this.cacheManager.get(cacheParams);
243297
if (cachedData) {
244-
//console.log('cache hit', tickerId, timeframe, limit, sDate, eDate);
245298
return cachedData;
246299
}
247300
}
@@ -257,63 +310,25 @@ export class BinanceProvider implements IProvider {
257310

258311
if (needsPagination) {
259312
if (sDate && eDate) {
260-
// Forward pagination: Fetch all data using interval pagination, then apply limit
313+
// Forward pagination — already normalized by getMarketDataInterval
261314
const allData = await this.getMarketDataInterval(tickerId, timeframe, sDate, eDate);
262315
const result = limit ? allData.slice(0, limit) : allData;
263316

264-
// Cache the results with original params
265317
this.cacheManager.set(cacheParams, result);
266318
return result;
267319
} else if (limit && limit > 1000) {
268-
// Backward pagination: Fetch 'limit' candles backwards from eDate (or now)
320+
// Backward pagination — already normalized by getMarketDataBackwards
269321
const result = await this.getMarketDataBackwards(tickerId, timeframe, limit, eDate);
270322

271-
// Cache the results
272323
this.cacheManager.set(cacheParams, result);
273324
return result;
274325
}
275326
}
276327

277-
// Single request for <= 1000 candles
278-
const baseUrl = await this.getBaseUrl();
279-
let url = `${baseUrl}/klines?symbol=${tickerId}&interval=${interval}`;
280-
281-
//example https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1m&limit=1000
282-
if (limit) {
283-
url += `&limit=${Math.min(limit, 1000)}`; // Cap at 1000 for single request
284-
}
285-
286-
if (sDate) {
287-
url += `&startTime=${sDate}`;
288-
}
289-
if (eDate) {
290-
url += `&endTime=${eDate}`;
291-
}
292-
293-
const response = await fetch(url);
294-
if (!response.ok) {
295-
throw new Error(`HTTP error! status: ${response.status}`);
296-
}
297-
const result = await response.json();
328+
// Single chunk — fetch raw, then normalize
329+
const data = await this._fetchRawChunk(tickerId, timeframe, limit, sDate, eDate);
330+
this._normalizeCloseTime(data);
298331

299-
const data = result.map((item) => {
300-
return {
301-
openTime: parseInt(item[0]),
302-
open: parseFloat(item[1]),
303-
high: parseFloat(item[2]),
304-
low: parseFloat(item[3]),
305-
close: parseFloat(item[4]),
306-
volume: parseFloat(item[5]),
307-
closeTime: parseInt(item[6]),
308-
quoteAssetVolume: parseFloat(item[7]),
309-
numberOfTrades: parseInt(item[8]),
310-
takerBuyBaseAssetVolume: parseFloat(item[9]),
311-
takerBuyQuoteAssetVolume: parseFloat(item[10]),
312-
ignore: item[11],
313-
};
314-
});
315-
316-
// Cache the results
317332
if (shouldCache) {
318333
this.cacheManager.set(cacheParams, data);
319334
}

src/marketData/IProvider.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ export type ISymbolInfo = {
5555
target_price_low: number;
5656
target_price_median: number;
5757
};
58+
/**
59+
* Market data provider interface.
60+
*
61+
* ## closeTime convention
62+
* Providers MUST return `closeTime` following the TradingView convention:
63+
* `closeTime` = the timestamp of the **start of the next bar** (not the last
64+
* millisecond of the current bar). For example, a weekly bar opening on
65+
* Monday 2019-01-07T00:00Z should have `closeTime = 2019-01-14T00:00Z`.
66+
*
67+
* If a provider's raw data uses a different convention (e.g., Binance returns
68+
* `nextBarOpen - 1ms`), the provider must normalize before returning.
69+
*/
5870
export interface IProvider {
5971
getMarketData(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise<any>;
6072
getSymbolInfo(tickerId: string): Promise<ISymbolInfo>;

0 commit comments

Comments
 (0)