From 90f8033974eaba039d435af4fb3e9f5dd4fe64c5 Mon Sep 17 00:00:00 2001 From: Nox Date: Mon, 22 Jun 2026 22:52:21 +0200 Subject: [PATCH 1/2] Fix TradingView indicator CI tests Authored-by: Nox --- .github/workflows/tests.yml | 2 +- src/chart/study.js | 17 +++++++++--- src/protocol.js | 54 +++++++++++++++++++++++++++++++++---- tests/indicators.test.ts | 8 +++--- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9582148e..c2d60096 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: - name: Install dependencies run: npm ci - name: Run tests - run: npm test + run: npm test -- --run env: SESSION: ${{ secrets.TW_SESSION }} SIGNATURE: ${{ secrets.TW_SIGNATURE }} diff --git a/src/chart/study.js b/src/chart/study.js index 4735d818..4e5cfbfe 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -64,7 +64,7 @@ const parseTrades = (trades) => trades.reverse().map((t) => ({ * @prop {Object} exit Trade exit * @prop {'' | string} exit.name Trade name ('' if false exit) - * @prop {number} exit.value Exit price value + * @prop {number} exit.value Exit value * @prop {number} exit.time Exit timestamp * @prop {number} quantity Trade quantity @@ -92,8 +92,7 @@ const parseTrades = (trades) => trades.reverse().map((t) => ({ * @prop {number} grossProfitPercent Gross profit percent * @prop {number} largestLosTrade Largest losing trade gain * @prop {number} largestLosTradePercent Largent losing trade performance (percentage) - * @prop {number} largestWinTrade Largest winning trade gain - * @prop {number} largestWinTradePercent Largest winning trade performance (percentage) + * @prop {number} largestWinTrade Largest winning trade performance (percentage) * @prop {number} marginCalls Margin calls * @prop {number} maxContractsHeld Max Contracts Held * @prop {number} netProfit Net profit @@ -341,7 +340,17 @@ module.exports = (chartSession) => class ChartStudy { }; if (parsed.dataCompressed) { - updateStrategyReport((await parseCompressed(parsed.dataCompressed)).report); + try { + const compressedData = await parseCompressed(parsed.dataCompressed); + if (compressedData && compressedData.report) { + updateStrategyReport(compressedData.report); + } + } catch (error) { + this.#handleError( + 'Unable to parse compressed strategy report:', + error.message || error, + ); + } } if (parsed.data && parsed.data.report) updateStrategyReport(parsed.data.report); diff --git a/src/protocol.js b/src/protocol.js index 73f543d7..6ce42cd8 100644 --- a/src/protocol.js +++ b/src/protocol.js @@ -1,3 +1,4 @@ +const zlib = require('zlib'); const JSZip = require('jszip'); /** @@ -9,6 +10,41 @@ const JSZip = require('jszip'); const cleanerRgx = /~h~/g; const splitterRgx = /~m~[0-9]{1,}~m~/g; +/** + * Normalise base64 data received from TradingView. + * TradingView occasionally omits padding and may use URL-safe characters. + * @param {string} data Base64 payload + * @returns {string} Normalised base64 payload + */ +function normaliseBase64(data) { + const normalised = data.replace(/-/g, '+').replace(/_/g, '/'); + return normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '='); +} + +/** + * Parse JSON from a decoded buffer, optionally trying common compression wrappers. + * @param {Buffer} buffer Decoded compressed payload + * @returns {Object | undefined} Parsed JSON when a format matches + */ +function parseDecodedCompressed(buffer) { + const readers = [ + () => buffer, + () => zlib.inflateSync(buffer), + () => zlib.inflateRawSync(buffer), + () => zlib.gunzipSync(buffer), + ]; + + for (const read of readers) { + try { + return JSON.parse(read().toString('utf8')); + } catch (error) { + // Try the next known TradingView payload format. + } + } + + return undefined; +} + module.exports = { /** * Parse websocket packet @@ -50,11 +86,19 @@ module.exports = { * @returns {Promise<{}>} Parsed data */ async parseCompressed(data) { + const normalised = normaliseBase64(data); const zip = new JSZip(); - return JSON.parse( - await ( - await zip.loadAsync(data, { base64: true }) - ).file('').async('text'), - ); + + try { + const archive = await zip.loadAsync(normalised, { base64: true }); + const file = archive.file('') || archive.file(/.*/)[0]; + if (!file) throw new Error('Compressed payload does not contain a file'); + return JSON.parse(await file.async('text')); + } catch (zipError) { + const decoded = Buffer.from(normalised, 'base64'); + const parsed = parseDecodedCompressed(decoded); + if (parsed) return parsed; + throw zipError; + } }, }; diff --git a/tests/indicators.test.ts b/tests/indicators.test.ts index f7a5ec91..3860744a 100644 --- a/tests/indicators.test.ts +++ b/tests/indicators.test.ts @@ -65,7 +65,7 @@ describe('Indicators', () => { expect(chart.infos.full_name).toBe('BINANCE:BTCEUR'); }); - it.skipIf(noAuth).concurrent('gets performance data from SuperTrend strategy', async () => { + it.skipIf(noAuth)('gets performance data from SuperTrend strategy', async () => { const SuperTrend = new chart.Study(indicators.SuperTrend); let QTY = 10; @@ -95,7 +95,7 @@ describe('Indicators', () => { }, }); - if (QTY >= 50) { + if (perfReport?.all?.totalTrades !== undefined && QTY >= 50) { resolve(true); return; } @@ -112,9 +112,9 @@ describe('Indicators', () => { expect(perfResult).toBe(true); SuperTrend.remove(); - }, 10000); + }, 30000); - it.skipIf(noAuth).concurrent('gets data from MarketCipher B study', async () => { + it.skipIf(noAuth)('gets data from MarketCipher B study', async () => { const CipherB = new chart.Study(indicators.CipherB); const lastResult: any = await new Promise((resolve) => { From b89a0d09f4c444ed5b1a6c486f468448c8679589 Mon Sep 17 00:00:00 2001 From: Nox Date: Mon, 22 Jun 2026 22:54:51 +0200 Subject: [PATCH 2/2] Restore study report JSDoc Authored-by: Nox --- src/chart/study.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/chart/study.js b/src/chart/study.js index 4e5cfbfe..d39d8c16 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -64,7 +64,7 @@ const parseTrades = (trades) => trades.reverse().map((t) => ({ * @prop {Object} exit Trade exit * @prop {'' | string} exit.name Trade name ('' if false exit) - * @prop {number} exit.value Exit value + * @prop {number} exit.value Exit price value * @prop {number} exit.time Exit timestamp * @prop {number} quantity Trade quantity @@ -92,7 +92,8 @@ const parseTrades = (trades) => trades.reverse().map((t) => ({ * @prop {number} grossProfitPercent Gross profit percent * @prop {number} largestLosTrade Largest losing trade gain * @prop {number} largestLosTradePercent Largent losing trade performance (percentage) - * @prop {number} largestWinTrade Largest winning trade performance (percentage) + * @prop {number} largestWinTrade Largest winning trade gain + * @prop {number} largestWinTradePercent Largest winning trade performance (percentage) * @prop {number} marginCalls Margin calls * @prop {number} maxContractsHeld Max Contracts Held * @prop {number} netProfit Net profit