Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/images/sponsor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ PineTS enables algorithmic traders, quant developers and platforms to integrate
---

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

## What is PineTS?
Expand Down
12 changes: 7 additions & 5 deletions src/Context.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
40 changes: 39 additions & 1 deletion src/PineTS.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
'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
*/
Expand Down Expand Up @@ -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[],
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/Series.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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.
Expand Down
133 changes: 74 additions & 59 deletions src/marketData/Binance/BinanceProvider.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<any[]> {
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<any> {
try {
const interval = timeframe_to_binance[timeframe.toUpperCase()];
Expand Down Expand Up @@ -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);
Expand All @@ -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;

Expand All @@ -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;
}

Expand All @@ -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;
}
}
Expand All @@ -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);
}
Expand Down
12 changes: 12 additions & 0 deletions src/marketData/IProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
getSymbolInfo(tickerId: string): Promise<ISymbolInfo>;
Expand Down
18 changes: 18 additions & 0 deletions src/marketData/Mock/MockProvider.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
*/
Expand Down
Loading
Loading