From 6373e230851e39664b0434467372baf919079cfc Mon Sep 17 00:00:00 2001 From: alaa-eddine Date: Thu, 5 Mar 2026 13:11:05 +0100 Subject: [PATCH 1/3] matrix inv pinv fix + unit-tests --- src/namespaces/Plots.ts | 89 +++-- src/namespaces/matrix/methods/inv.ts | 86 ++++- src/namespaces/matrix/methods/pinv.ts | 80 ++++- .../namespaces/matrix/matrix-advanced.test.ts | 336 +++++++++++++++++- 4 files changed, 531 insertions(+), 60 deletions(-) diff --git a/src/namespaces/Plots.ts b/src/namespaces/Plots.ts index 4e998f3..56b2374 100644 --- a/src/namespaces/Plots.ts +++ b/src/namespaces/Plots.ts @@ -442,31 +442,80 @@ export class FillHelper { } any(...args) { const callsiteId = extractCallsiteId(args); - const _parsed = parseArgsForPineParams(args, FILL_SIGNATURE, FILL_ARGS_TYPES); - const { plot1, plot2, color, title, editable, show_last, fillgaps, display } = _parsed; - - // For fill: prefer title, then callsite ID, then generic fallback - let fillKey = title || 'fill'; - const existing = this.context.plots[fillKey]; - if (existing && callsiteId && existing._callsiteId !== callsiteId) { - fillKey = callsiteId; - } - if (!this.context.plots[fillKey]) { + // Detect gradient fill: fill(plot1, plot2, top_value, bottom_value, top_color, bottom_color, ...) + // vs simple fill: fill(plot1, plot2, color, title, ...) + // The 3rd positional arg (index 2) is a number (top_value) for gradient fills, + // but a color string for simple fills. + const isGradientFill = args.length >= 6 && typeof args[2] === 'number'; + + if (isGradientFill) { + const plot1 = args[0]; + const plot2 = args[1]; + const top_value = args[2]; + const bottom_value = args[3]; + const top_color = args[4]; + const bottom_color = args[5]; + const title = args.length > 6 && typeof args[6] === 'string' ? args[6] : undefined; + const p1Key = plot1?._plotKey || plot1?.title; const p2Key = plot2?._plotKey || plot2?.title; - this.context.plots[fillKey] = { - title: title || 'Fill', - plot1: p1Key, - plot2: p2Key, - options: { + let fillKey = title || 'fill'; + const existing = this.context.plots[fillKey]; + if (existing && callsiteId && existing._callsiteId !== callsiteId) { + fillKey = callsiteId; + } + + if (!this.context.plots[fillKey]) { + this.context.plots[fillKey] = { + title: title || 'Fill', plot1: p1Key, plot2: p2Key, - color, editable, show_last, fillgaps, display, style: 'fill', - }, - _plotKey: fillKey, - _callsiteId: callsiteId, - }; + data: [], + options: { + plot1: p1Key, + plot2: p2Key, + style: 'fill', + gradient: true, + }, + _plotKey: fillKey, + _callsiteId: callsiteId, + }; + } + + // Push per-bar gradient data + this.context.plots[fillKey].data.push({ + time: this.context.marketData[this.context.idx].openTime, + value: null, + options: { top_value, bottom_value, top_color, bottom_color }, + }); + } else { + const _parsed = parseArgsForPineParams(args, FILL_SIGNATURE, FILL_ARGS_TYPES); + const { plot1, plot2, color, title, editable, show_last, fillgaps, display } = _parsed; + + // For fill: prefer title, then callsite ID, then generic fallback + let fillKey = title || 'fill'; + const existing = this.context.plots[fillKey]; + if (existing && callsiteId && existing._callsiteId !== callsiteId) { + fillKey = callsiteId; + } + + if (!this.context.plots[fillKey]) { + const p1Key = plot1?._plotKey || plot1?.title; + const p2Key = plot2?._plotKey || plot2?.title; + this.context.plots[fillKey] = { + title: title || 'Fill', + plot1: p1Key, + plot2: p2Key, + options: { + plot1: p1Key, + plot2: p2Key, + color, editable, show_last, fillgaps, display, style: 'fill', + }, + _plotKey: fillKey, + _callsiteId: callsiteId, + }; + } } } } diff --git a/src/namespaces/matrix/methods/inv.ts b/src/namespaces/matrix/methods/inv.ts index 9a895b9..b7605e6 100644 --- a/src/namespaces/matrix/methods/inv.ts +++ b/src/namespaces/matrix/methods/inv.ts @@ -3,24 +3,82 @@ import { PineMatrixObject } from '../PineMatrixObject'; import { Context } from '../../../Context.class'; -// Simple inverse for 2x2 +/** + * Gauss-Jordan elimination to compute the inverse of an NxN matrix. + * Uses partial pivoting for numerical stability. + * Returns a matrix of NaN if the matrix is singular. + */ function inverse(matrix: number[][]): number[][] { const n = matrix.length; - if (n !== 2) return matrix.map((r) => r.map(() => NaN)); // Only 2x2 supported - - const det = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0]; - if (det === 0) - return [ - [NaN, NaN], - [NaN, NaN], - ]; - - return [ - [matrix[1][1] / det, -matrix[0][1] / det], - [-matrix[1][0] / det, matrix[0][0] / det], - ]; + if (n === 0) return []; + + // Build augmented matrix [A | I] + const aug: number[][] = new Array(n); + for (let i = 0; i < n; i++) { + aug[i] = new Array(2 * n); + for (let j = 0; j < n; j++) { + aug[i][j] = matrix[i][j]; + } + for (let j = 0; j < n; j++) { + aug[i][n + j] = i === j ? 1 : 0; + } + } + + // Forward elimination with partial pivoting + for (let col = 0; col < n; col++) { + // Find pivot (row with largest absolute value in this column) + let maxVal = Math.abs(aug[col][col]); + let maxRow = col; + for (let row = col + 1; row < n; row++) { + const absVal = Math.abs(aug[row][col]); + if (absVal > maxVal) { + maxVal = absVal; + maxRow = row; + } + } + + // If pivot is zero (or near-zero), matrix is singular + if (maxVal < 1e-14) { + return matrix.map((r) => r.map(() => NaN)); + } + + // Swap rows if necessary + if (maxRow !== col) { + const temp = aug[col]; + aug[col] = aug[maxRow]; + aug[maxRow] = temp; + } + + // Scale pivot row + const pivot = aug[col][col]; + for (let j = col; j < 2 * n; j++) { + aug[col][j] /= pivot; + } + + // Eliminate column in all other rows + for (let row = 0; row < n; row++) { + if (row === col) continue; + const factor = aug[row][col]; + if (factor === 0) continue; + for (let j = col; j < 2 * n; j++) { + aug[row][j] -= factor * aug[col][j]; + } + } + } + + // Extract inverse from right half of augmented matrix + const result: number[][] = new Array(n); + for (let i = 0; i < n; i++) { + result[i] = new Array(n); + for (let j = 0; j < n; j++) { + result[i][j] = aug[i][n + j]; + } + } + return result; } +export { inverse }; + export function inv(context: Context) { return (id: PineMatrixObject) => { const rows = id.matrix.length; diff --git a/src/namespaces/matrix/methods/pinv.ts b/src/namespaces/matrix/methods/pinv.ts index f4b7a29..b4b7a73 100644 --- a/src/namespaces/matrix/methods/pinv.ts +++ b/src/namespaces/matrix/methods/pinv.ts @@ -2,19 +2,79 @@ import { PineMatrixObject } from '../PineMatrixObject'; import { Context } from '../../../Context.class'; +import { inverse } from './inv'; +/** + * Moore-Penrose pseudoinverse. + * - Square matrix: pinv(A) = inv(A) + * - Tall matrix (m > n): pinv(A) = (A^T A)^-1 A^T + * - Wide matrix (m < n): pinv(A) = A^T (A A^T)^-1 + */ export function pinv(context: Context) { return (id: PineMatrixObject) => { - // Pseudoinverse placeholder (uses inv if square/nonsingular, else NaN for now) - const rows = id.matrix.length; - const cols = rows > 0 ? id.matrix[0].length : 0; - - if (rows === cols) { - // Try normal inverse - // We need to import inv implementation or duplicate logic. - // Since we don't have shared math lib yet, let's return NaN for complex cases. - return new PineMatrixObject(rows, cols, NaN, context); + const m = id.matrix.length; + if (m === 0) return new PineMatrixObject(0, 0, NaN, context); + const n = id.matrix[0].length; + + if (m === n) { + // Square matrix — pseudoinverse is just the regular inverse + const invMat = inverse(id.matrix); + const result = new PineMatrixObject(m, n, NaN, context); + result.matrix = invMat; + return result; } - return new PineMatrixObject(cols, rows, NaN, context); + + // Helper: transpose a raw 2D array + const transposeRaw = (mat: number[][]): number[][] => { + const rows = mat.length; + const cols = mat[0].length; + const t: number[][] = new Array(cols); + for (let i = 0; i < cols; i++) { + t[i] = new Array(rows); + for (let j = 0; j < rows; j++) { + t[i][j] = mat[j][i]; + } + } + return t; + }; + + // Helper: multiply two raw 2D arrays + const multiplyRaw = (a: number[][], b: number[][]): number[][] => { + const aRows = a.length; + const aCols = a[0].length; + const bCols = b[0].length; + const result: number[][] = new Array(aRows); + for (let i = 0; i < aRows; i++) { + result[i] = new Array(bCols).fill(0); + for (let k = 0; k < aCols; k++) { + const aik = a[i][k]; + if (aik === 0) continue; + for (let j = 0; j < bCols; j++) { + result[i][j] += aik * b[k][j]; + } + } + } + return result; + }; + + const A = id.matrix; + const AT = transposeRaw(A); + + let pinvMat: number[][]; + if (m > n) { + // Tall: pinv(A) = (A^T A)^-1 A^T + const ATA = multiplyRaw(AT, A); // n x n + const ATAinv = inverse(ATA); // n x n + pinvMat = multiplyRaw(ATAinv, AT); // n x m + } else { + // Wide: pinv(A) = A^T (A A^T)^-1 + const AAT = multiplyRaw(A, AT); // m x m + const AATinv = inverse(AAT); // m x m + pinvMat = multiplyRaw(AT, AATinv); // n x m + } + + const result = new PineMatrixObject(n, m, NaN, context); + result.matrix = pinvMat; + return result; }; } diff --git a/tests/namespaces/matrix/matrix-advanced.test.ts b/tests/namespaces/matrix/matrix-advanced.test.ts index 0b7ffe5..ce98c71 100644 --- a/tests/namespaces/matrix/matrix-advanced.test.ts +++ b/tests/namespaces/matrix/matrix-advanced.test.ts @@ -722,27 +722,188 @@ describe('Matrix Methods - Advanced Operations', () => { expect(typeof plots['det'].data[0].value).toBe('number'); }); - it('should compute matrix inverse', async () => { + it('should compute 2x2 matrix inverse with correct values', async () => { const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + // [[4, 7], [2, 6]] → inv = 1/(24-14) * [[6, -7], [-2, 4]] = [[0.6, -0.7], [-0.2, 0.4]] const code = ` const { matrix, plotchar } = context.pine; - + let m = matrix.new(2, 2, 0); matrix.set(m, 0, 0, 4); matrix.set(m, 0, 1, 7); matrix.set(m, 1, 0, 2); matrix.set(m, 1, 1, 6); - + let inv = matrix.inv(m); - let val00 = matrix.get(inv, 0, 0); - - plotchar(val00, 'val00'); + plotchar(matrix.get(inv, 0, 0), 'v00'); + plotchar(matrix.get(inv, 0, 1), 'v01'); + plotchar(matrix.get(inv, 1, 0), 'v10'); + plotchar(matrix.get(inv, 1, 1), 'v11'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['v00'].data[0].value).toBeCloseTo(0.6, 10); + expect(plots['v01'].data[0].value).toBeCloseTo(-0.7, 10); + expect(plots['v10'].data[0].value).toBeCloseTo(-0.2, 10); + expect(plots['v11'].data[0].value).toBeCloseTo(0.4, 10); + }); + + it('should compute 3x3 matrix inverse (A * A^-1 = I)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // [[1,2,3],[0,1,4],[5,6,0]] — verify A * inv(A) = I + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(3, 3, 0); + matrix.set(m, 0, 0, 1); matrix.set(m, 0, 1, 2); matrix.set(m, 0, 2, 3); + matrix.set(m, 1, 0, 0); matrix.set(m, 1, 1, 1); matrix.set(m, 1, 2, 4); + matrix.set(m, 2, 0, 5); matrix.set(m, 2, 1, 6); matrix.set(m, 2, 2, 0); + + let inv = matrix.inv(m); + let product = matrix.mult(m, inv); + + // Diagonal should be 1, off-diagonal should be 0 + plotchar(matrix.get(product, 0, 0), 'd00'); + plotchar(matrix.get(product, 1, 1), 'd11'); + plotchar(matrix.get(product, 2, 2), 'd22'); + plotchar(matrix.get(product, 0, 1), 'o01'); + plotchar(matrix.get(product, 1, 0), 'o10'); + plotchar(matrix.get(product, 0, 2), 'o02'); + `; + + const { plots } = await pineTS.run(code); + // Diagonal elements should be 1 + expect(plots['d00'].data[0].value).toBeCloseTo(1, 8); + expect(plots['d11'].data[0].value).toBeCloseTo(1, 8); + expect(plots['d22'].data[0].value).toBeCloseTo(1, 8); + // Off-diagonal elements should be 0 + expect(plots['o01'].data[0].value).toBeCloseTo(0, 8); + expect(plots['o10'].data[0].value).toBeCloseTo(0, 8); + expect(plots['o02'].data[0].value).toBeCloseTo(0, 8); + }); + + it('should compute 4x4 matrix inverse', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // 4x4 matrix — verify A * inv(A) diagonal = 1 + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(4, 4, 0); + matrix.set(m, 0, 0, 2); matrix.set(m, 0, 1, 1); matrix.set(m, 0, 2, 0); matrix.set(m, 0, 3, 1); + matrix.set(m, 1, 0, 3); matrix.set(m, 1, 1, 2); matrix.set(m, 1, 2, 1); matrix.set(m, 1, 3, 0); + matrix.set(m, 2, 0, 1); matrix.set(m, 2, 1, 0); matrix.set(m, 2, 2, 2); matrix.set(m, 2, 3, 1); + matrix.set(m, 3, 0, 0); matrix.set(m, 3, 1, 1); matrix.set(m, 3, 2, 1); matrix.set(m, 3, 3, 3); + + let inv = matrix.inv(m); + let product = matrix.mult(m, inv); + + plotchar(matrix.get(product, 0, 0), 'd00'); + plotchar(matrix.get(product, 1, 1), 'd11'); + plotchar(matrix.get(product, 2, 2), 'd22'); + plotchar(matrix.get(product, 3, 3), 'd33'); + plotchar(matrix.get(product, 0, 3), 'o03'); + plotchar(matrix.get(product, 2, 1), 'o21'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['d00'].data[0].value).toBeCloseTo(1, 8); + expect(plots['d11'].data[0].value).toBeCloseTo(1, 8); + expect(plots['d22'].data[0].value).toBeCloseTo(1, 8); + expect(plots['d33'].data[0].value).toBeCloseTo(1, 8); + expect(plots['o03'].data[0].value).toBeCloseTo(0, 8); + expect(plots['o21'].data[0].value).toBeCloseTo(0, 8); + }); + + it('should return identity when inverting identity matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(3, 3, 0); + matrix.set(m, 0, 0, 1); + matrix.set(m, 1, 1, 1); + matrix.set(m, 2, 2, 1); + + let inv = matrix.inv(m); + plotchar(matrix.get(inv, 0, 0), 'd00'); + plotchar(matrix.get(inv, 1, 1), 'd11'); + plotchar(matrix.get(inv, 2, 2), 'd22'); + plotchar(matrix.get(inv, 0, 1), 'o01'); + plotchar(matrix.get(inv, 1, 2), 'o12'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['d00'].data[0].value).toBeCloseTo(1, 10); + expect(plots['d11'].data[0].value).toBeCloseTo(1, 10); + expect(plots['d22'].data[0].value).toBeCloseTo(1, 10); + expect(plots['o01'].data[0].value).toBeCloseTo(0, 10); + expect(plots['o12'].data[0].value).toBeCloseTo(0, 10); + }); + + it('should return NaN for singular matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // [[1,2],[2,4]] is singular (det=0, row 2 = 2 * row 1) + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(2, 2, 0); + matrix.set(m, 0, 0, 1); + matrix.set(m, 0, 1, 2); + matrix.set(m, 1, 0, 2); + matrix.set(m, 1, 1, 4); + + let inv = matrix.inv(m); + plotchar(matrix.get(inv, 0, 0), 'v00'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['v00'].data[0].value).toBeNaN(); + }); + + it('should return NaN for non-square matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(2, 3, 1); + let inv = matrix.inv(m); + plotchar(matrix.get(inv, 0, 0), 'v00'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['v00'].data[0].value).toBeNaN(); + }); + + it('should compute inverse of diagonal matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // diag(2, 5, 4) → inv = diag(1/2, 1/5, 1/4) + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(3, 3, 0); + matrix.set(m, 0, 0, 2); + matrix.set(m, 1, 1, 5); + matrix.set(m, 2, 2, 4); + + let inv = matrix.inv(m); + plotchar(matrix.get(inv, 0, 0), 'v00'); + plotchar(matrix.get(inv, 1, 1), 'v11'); + plotchar(matrix.get(inv, 2, 2), 'v22'); + plotchar(matrix.get(inv, 0, 1), 'o01'); `; const { plots } = await pineTS.run(code); - // Inverse exists if determinant != 0 - expect(plots['val00'].data[0].value).toBeDefined(); + expect(plots['v00'].data[0].value).toBeCloseTo(0.5, 10); + expect(plots['v11'].data[0].value).toBeCloseTo(0.2, 10); + expect(plots['v22'].data[0].value).toBeCloseTo(0.25, 10); + expect(plots['o01'].data[0].value).toBeCloseTo(0, 10); }); it('should compute matrix rank', async () => { @@ -833,12 +994,13 @@ describe('Matrix Methods - Advanced Operations', () => { expect(plots['rows'].data[0].value).toBeGreaterThan(0); }); - it('should compute pseudo-inverse', async () => { + it('should compute pseudo-inverse of wide matrix (m < n) with correct dimensions', async () => { const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + // 2x3 wide matrix → pinv should be 3x2 const code = ` const { matrix, plotchar } = context.pine; - + let m = matrix.new(2, 3, 0); matrix.set(m, 0, 0, 1); matrix.set(m, 0, 1, 2); @@ -846,13 +1008,10 @@ describe('Matrix Methods - Advanced Operations', () => { matrix.set(m, 1, 0, 4); matrix.set(m, 1, 1, 5); matrix.set(m, 1, 2, 6); - + let pinv = matrix.pinv(m); - let rows = matrix.rows(pinv); - let cols = matrix.columns(pinv); - - plotchar(rows, 'rows'); - plotchar(cols, 'cols'); + plotchar(matrix.rows(pinv), 'rows'); + plotchar(matrix.columns(pinv), 'cols'); `; const { plots } = await pineTS.run(code); @@ -860,6 +1019,151 @@ describe('Matrix Methods - Advanced Operations', () => { expect(plots['cols'].data[0].value).toBe(2); }); + it('should compute pseudo-inverse of tall matrix (m > n) with correct dimensions', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // 3x2 tall matrix → pinv should be 2x3 + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(3, 2, 0); + matrix.set(m, 0, 0, 1); matrix.set(m, 0, 1, 2); + matrix.set(m, 1, 0, 3); matrix.set(m, 1, 1, 4); + matrix.set(m, 2, 0, 5); matrix.set(m, 2, 1, 6); + + let pinv = matrix.pinv(m); + plotchar(matrix.rows(pinv), 'rows'); + plotchar(matrix.columns(pinv), 'cols'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['rows'].data[0].value).toBe(2); + expect(plots['cols'].data[0].value).toBe(3); + }); + + it('should satisfy A * pinv(A) * A ≈ A for tall matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // Moore-Penrose property: A * pinv(A) * A = A + const code = ` + const { matrix, plotchar } = context.pine; + + let A = matrix.new(3, 2, 0); + matrix.set(A, 0, 0, 1); matrix.set(A, 0, 1, 2); + matrix.set(A, 1, 0, 3); matrix.set(A, 1, 1, 4); + matrix.set(A, 2, 0, 5); matrix.set(A, 2, 1, 6); + + let Ap = matrix.pinv(A); + let AApA = matrix.mult(matrix.mult(A, Ap), A); + + plotchar(matrix.get(AApA, 0, 0), 'r00'); + plotchar(matrix.get(AApA, 0, 1), 'r01'); + plotchar(matrix.get(AApA, 1, 0), 'r10'); + plotchar(matrix.get(AApA, 1, 1), 'r11'); + plotchar(matrix.get(AApA, 2, 0), 'r20'); + plotchar(matrix.get(AApA, 2, 1), 'r21'); + `; + + const { plots } = await pineTS.run(code); + // A * pinv(A) * A should equal A + expect(plots['r00'].data[0].value).toBeCloseTo(1, 6); + expect(plots['r01'].data[0].value).toBeCloseTo(2, 6); + expect(plots['r10'].data[0].value).toBeCloseTo(3, 6); + expect(plots['r11'].data[0].value).toBeCloseTo(4, 6); + expect(plots['r20'].data[0].value).toBeCloseTo(5, 6); + expect(plots['r21'].data[0].value).toBeCloseTo(6, 6); + }); + + it('should satisfy A * pinv(A) * A ≈ A for wide matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + const code = ` + const { matrix, plotchar } = context.pine; + + let A = matrix.new(2, 3, 0); + matrix.set(A, 0, 0, 1); matrix.set(A, 0, 1, 2); matrix.set(A, 0, 2, 3); + matrix.set(A, 1, 0, 4); matrix.set(A, 1, 1, 5); matrix.set(A, 1, 2, 6); + + let Ap = matrix.pinv(A); + let AApA = matrix.mult(matrix.mult(A, Ap), A); + + plotchar(matrix.get(AApA, 0, 0), 'r00'); + plotchar(matrix.get(AApA, 0, 1), 'r01'); + plotchar(matrix.get(AApA, 0, 2), 'r02'); + plotchar(matrix.get(AApA, 1, 0), 'r10'); + plotchar(matrix.get(AApA, 1, 1), 'r11'); + plotchar(matrix.get(AApA, 1, 2), 'r12'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['r00'].data[0].value).toBeCloseTo(1, 6); + expect(plots['r01'].data[0].value).toBeCloseTo(2, 6); + expect(plots['r02'].data[0].value).toBeCloseTo(3, 6); + expect(plots['r10'].data[0].value).toBeCloseTo(4, 6); + expect(plots['r11'].data[0].value).toBeCloseTo(5, 6); + expect(plots['r12'].data[0].value).toBeCloseTo(6, 6); + }); + + it('should equal regular inverse for square matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // For square invertible matrix, pinv(A) = inv(A) + const code = ` + const { matrix, plotchar } = context.pine; + + let m = matrix.new(2, 2, 0); + matrix.set(m, 0, 0, 4); matrix.set(m, 0, 1, 7); + matrix.set(m, 1, 0, 2); matrix.set(m, 1, 1, 6); + + let pinvM = matrix.pinv(m); + let invM = matrix.inv(m); + + plotchar(matrix.get(pinvM, 0, 0), 'p00'); + plotchar(matrix.get(pinvM, 0, 1), 'p01'); + plotchar(matrix.get(pinvM, 1, 0), 'p10'); + plotchar(matrix.get(pinvM, 1, 1), 'p11'); + plotchar(matrix.get(invM, 0, 0), 'i00'); + plotchar(matrix.get(invM, 0, 1), 'i01'); + plotchar(matrix.get(invM, 1, 0), 'i10'); + plotchar(matrix.get(invM, 1, 1), 'i11'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['p00'].data[0].value).toBeCloseTo(plots['i00'].data[0].value, 8); + expect(plots['p01'].data[0].value).toBeCloseTo(plots['i01'].data[0].value, 8); + expect(plots['p10'].data[0].value).toBeCloseTo(plots['i10'].data[0].value, 8); + expect(plots['p11'].data[0].value).toBeCloseTo(plots['i11'].data[0].value, 8); + }); + + it('should compute pinv for column vector (Nx1)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + // pinv of column vector [a; b; c] = [a b c] / (a² + b² + c²) + const code = ` + const { matrix, plotchar } = context.pine; + + let v = matrix.new(3, 1, 0); + matrix.set(v, 0, 0, 1); + matrix.set(v, 1, 0, 2); + matrix.set(v, 2, 0, 3); + + let pinvV = matrix.pinv(v); + plotchar(matrix.rows(pinvV), 'rows'); + plotchar(matrix.columns(pinvV), 'cols'); + // pinv([1;2;3]) = [1 2 3]/(1+4+9) = [1/14, 2/14, 3/14] + plotchar(matrix.get(pinvV, 0, 0), 'v00'); + plotchar(matrix.get(pinvV, 0, 1), 'v01'); + plotchar(matrix.get(pinvV, 0, 2), 'v02'); + `; + + const { plots } = await pineTS.run(code); + expect(plots['rows'].data[0].value).toBe(1); + expect(plots['cols'].data[0].value).toBe(3); + expect(plots['v00'].data[0].value).toBeCloseTo(1/14, 8); + expect(plots['v01'].data[0].value).toBeCloseTo(2/14, 8); + expect(plots['v02'].data[0].value).toBeCloseTo(3/14, 8); + }); + it('should compute matrix multiplication (scalar)', async () => { const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); From 34893eaccf3e81425309b87ffcd08688e92adcb9 Mon Sep 17 00:00:00 2001 From: alaa-eddine Date: Thu, 5 Mar 2026 16:04:13 +0100 Subject: [PATCH 2/3] array.* optimizations --- src/Context.class.ts | 4 +- src/namespaces/array/methods/max.ts | 16 +- src/namespaces/array/methods/median.ts | 17 +- src/namespaces/array/methods/min.ts | 16 +- .../percentile_linear_interpolation.ts | 22 +- .../array/methods/percentile_nearest_rank.ts | 15 +- tests/namespaces/array/min-max.test.ts | 275 ++++++++++++++++++ 7 files changed, 330 insertions(+), 35 deletions(-) create mode 100644 tests/namespaces/array/min-max.test.ts diff --git a/src/Context.class.ts b/src/Context.class.ts index b87b01e..0f157d2 100644 --- a/src/Context.class.ts +++ b/src/Context.class.ts @@ -559,8 +559,10 @@ export class Context { * @param decimals - the number of decimals to precision to * @returns the precision number */ + private static readonly PRECISION_EPSILON = 10 ** 10; // Cache default epsilon + precision(value: number, decimals: number = 10) { - const epsilon = 10 ** decimals; + const epsilon = decimals === 10 ? Context.PRECISION_EPSILON : 10 ** decimals; return typeof value === 'number' ? Math.round(value * epsilon) / epsilon : value; //if (typeof n !== 'number' || isNaN(n)) return n; //return Number(n.toFixed(decimals)); diff --git a/src/namespaces/array/methods/max.ts b/src/namespaces/array/methods/max.ts index 09dafd6..258f239 100644 --- a/src/namespaces/array/methods/max.ts +++ b/src/namespaces/array/methods/max.ts @@ -4,8 +4,20 @@ import { PineArrayObject } from '../PineArrayObject'; export function max(context: any) { return (id: PineArrayObject, nth: number = 0): number => { - const sorted = [...id.array].sort((a, b) => b - a); + const arr = id.array; + if (arr.length === 0) return context.NA; + + // Fast path: nth=0 (most common) — O(N) linear scan instead of O(N log N) sort + if (nth === 0) { + let maxVal = arr[0]; + for (let i = 1; i < arr.length; i++) { + if (arr[i] > maxVal) maxVal = arr[i]; + } + return maxVal ?? context.NA; + } + + // nth > 0: need sorted order — still requires O(N log N) + const sorted = [...arr].sort((a, b) => b - a); return sorted[nth] ?? context.NA; }; } - diff --git a/src/namespaces/array/methods/median.ts b/src/namespaces/array/methods/median.ts index e536fcd..d708f3a 100644 --- a/src/namespaces/array/methods/median.ts +++ b/src/namespaces/array/methods/median.ts @@ -4,18 +4,13 @@ import { PineArrayObject } from '../PineArrayObject'; export function median(context: any) { return (id: PineArrayObject) => { - if (id.array.length === 0) return NaN; + const arr = id.array; + if (arr.length === 0) return NaN; - // Filter out non-numeric values if necessary? Pine Script arrays are typed. - // Assuming numeric array for median. - - // Create a copy to sort - const sorted = [...id.array].sort((a, b) => { - if (typeof a === 'number' && typeof b === 'number') { - return a - b; - } - return 0; - }); + // Sort a copy (unavoidable for median — need the middle element(s)) + const sorted = new Array(arr.length); + for (let i = 0; i < arr.length; i++) sorted[i] = arr[i]; + sorted.sort((a: number, b: number) => a - b); const mid = Math.floor(sorted.length / 2); diff --git a/src/namespaces/array/methods/min.ts b/src/namespaces/array/methods/min.ts index 5e9533d..3b0cc1f 100644 --- a/src/namespaces/array/methods/min.ts +++ b/src/namespaces/array/methods/min.ts @@ -4,8 +4,20 @@ import { PineArrayObject } from '../PineArrayObject'; export function min(context: any) { return (id: PineArrayObject, nth: number = 0): number => { - const sorted = [...id.array].sort((a, b) => a - b); + const arr = id.array; + if (arr.length === 0) return context.NA; + + // Fast path: nth=0 (most common) — O(N) linear scan instead of O(N log N) sort + if (nth === 0) { + let minVal = arr[0]; + for (let i = 1; i < arr.length; i++) { + if (arr[i] < minVal) minVal = arr[i]; + } + return minVal ?? context.NA; + } + + // nth > 0: need sorted order — still requires O(N log N) + const sorted = [...arr].sort((a, b) => a - b); return sorted[nth] ?? context.NA; }; } - diff --git a/src/namespaces/array/methods/percentile_linear_interpolation.ts b/src/namespaces/array/methods/percentile_linear_interpolation.ts index 6351558..11f8936 100644 --- a/src/namespaces/array/methods/percentile_linear_interpolation.ts +++ b/src/namespaces/array/methods/percentile_linear_interpolation.ts @@ -6,33 +6,35 @@ import { Context } from '../../../Context.class'; export function percentile_linear_interpolation(context: Context) { return (id: PineArrayObject, percentage: number): number => { const array = id.array; - if (array.length === 0) return NaN; + const len = array.length; + if (len === 0) return NaN; - const validValues: number[] = []; - for (const item of array) { - const val = Number(item); + // Validate and copy in a single pass (avoid separate validValues allocation) + const sorted = new Array(len); + for (let i = 0; i < len; i++) { + const val = Number(array[i]); if (isNaN(val) || val === null || val === undefined) { return NaN; // Propagate NaN if any value is invalid } - validValues.push(val); + sorted[i] = val; } - validValues.sort((a, b) => a - b); + sorted.sort((a: number, b: number) => a - b); if (percentage < 0) percentage = 0; if (percentage > 100) percentage = 100; // Pine Script seems to use the formula: k = (p/100) * N - 0.5 // This corresponds to the Hazen plotting position definition. - const k = (percentage / 100) * validValues.length - 0.5; + const k = (percentage / 100) * len - 0.5; // Handle boundaries - if (k <= 0) return context.precision(validValues[0]); - if (k >= validValues.length - 1) return context.precision(validValues[validValues.length - 1]); + if (k <= 0) return context.precision(sorted[0]); + if (k >= len - 1) return context.precision(sorted[len - 1]); const i = Math.floor(k); const f = k - i; - return context.precision(validValues[i] * (1 - f) + validValues[i + 1] * f); + return context.precision(sorted[i] * (1 - f) + sorted[i + 1] * f); }; } diff --git a/src/namespaces/array/methods/percentile_nearest_rank.ts b/src/namespaces/array/methods/percentile_nearest_rank.ts index d683abb..1d7b171 100644 --- a/src/namespaces/array/methods/percentile_nearest_rank.ts +++ b/src/namespaces/array/methods/percentile_nearest_rank.ts @@ -5,11 +5,13 @@ import { PineArrayObject } from '../PineArrayObject'; export function percentile_nearest_rank(context: any) { return (id: PineArrayObject, percentage: number): number => { const array = id.array; - if (array.length === 0) return NaN; + const totalCount = array.length; + if (totalCount === 0) return NaN; - const validValues: number[] = []; - for (const item of array) { - const val = Number(item); + // Single pass: validate and copy + const validValues = new Array(); + for (let i = 0; i < totalCount; i++) { + const val = Number(array[i]); if (!isNaN(val) && val !== null && val !== undefined) { validValues.push(val); } @@ -25,12 +27,9 @@ export function percentile_nearest_rank(context: any) { // Nearest Rank Method // Use total array length (including NaNs) for calculation to match Pine Script behavior // observed in tests where NaNs dilute the percentile rank. - const totalCount = array.length; const rank = Math.ceil((percentage / 100) * totalCount); if (rank <= 0) { - // If P=0, usually return min, but strictly following formula rank=0 is invalid index. - // If we assume P=0 maps to index 0: return validValues[0]; } @@ -39,5 +38,3 @@ export function percentile_nearest_rank(context: any) { return validValues[rank - 1]; }; } - - diff --git a/tests/namespaces/array/min-max.test.ts b/tests/namespaces/array/min-max.test.ts new file mode 100644 index 0000000..84c4126 --- /dev/null +++ b/tests/namespaces/array/min-max.test.ts @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2025 Alaa-eddine KADDOURI + +import { describe, it, expect } from 'vitest'; +import { PineTS } from '../../../src/PineTS.class'; +import { Provider } from '@pinets/marketData/Provider.class'; + +describe('array.min() and array.max()', () => { + const startDate = new Date('2024-01-01').getTime(); + const endDate = new Date('2024-01-05').getTime(); + + // ── array.min() ────────────────────────────────────── + + describe('array.min()', () => { + it('should return minimum of a basic array', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.min(arr), 'min'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min'].data[0].value).toBe(10); + }); + + it('should return minimum of a single-element array', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(42); + plotchar(array.min(arr), 'min'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min'].data[0].value).toBe(42); + }); + + it('should handle negative values', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(5, -3, 10, -7, 2); + plotchar(array.min(arr), 'min'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min'].data[0].value).toBe(-7); + }); + + it('should handle all identical values', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.new(5, 7.5); + plotchar(array.min(arr), 'min'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min'].data[0].value).toBe(7.5); + }); + + it('should handle fractional values', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(1.5, 0.3, 0.7, 2.1); + plotchar(array.min(arr), 'min'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min'].data[0].value).toBeCloseTo(0.3, 10); + }); + + it('should return nth smallest with nth=1 (2nd smallest)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.min(arr, 1), 'min_nth'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min_nth'].data[0].value).toBe(20); + }); + + it('should return nth smallest with nth=2 (3rd smallest)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.min(arr, 2), 'min_nth'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min_nth'].data[0].value).toBe(30); + }); + + it('should return last element when nth = size-1', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.min(arr, 4), 'min_last'); + `; + const { plots } = await pineTS.run(code); + // 5th smallest = maximum = 50 + expect(plots['min_last'].data[0].value).toBe(50); + }); + + it('should handle empty array', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.new_float(0); + plotchar(array.min(arr), 'min'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min'].data[0].value).toBeNaN(); + }); + + it('should handle large array (100 elements)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.new_float(0); + for (let i = 100; i >= 1; i--) { + array.push(arr, i); + } + plotchar(array.min(arr), 'min'); + plotchar(array.min(arr, 99), 'max_via_nth'); + `; + const { plots } = await pineTS.run(code); + expect(plots['min'].data[0].value).toBe(1); + expect(plots['max_via_nth'].data[0].value).toBe(100); + }); + }); + + // ── array.max() ────────────────────────────────────── + + describe('array.max()', () => { + it('should return maximum of a basic array', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.max(arr), 'max'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max'].data[0].value).toBe(50); + }); + + it('should return maximum of a single-element array', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(42); + plotchar(array.max(arr), 'max'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max'].data[0].value).toBe(42); + }); + + it('should handle negative values', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(-5, -3, -10, -7, -2); + plotchar(array.max(arr), 'max'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max'].data[0].value).toBe(-2); + }); + + it('should handle all identical values', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.new(5, 7.5); + plotchar(array.max(arr), 'max'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max'].data[0].value).toBe(7.5); + }); + + it('should return nth largest with nth=1 (2nd largest)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.max(arr, 1), 'max_nth'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max_nth'].data[0].value).toBe(40); + }); + + it('should return nth largest with nth=2 (3rd largest)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.max(arr, 2), 'max_nth'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max_nth'].data[0].value).toBe(30); + }); + + it('should return last element when nth = size-1', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.from(30, 10, 50, 20, 40); + plotchar(array.max(arr, 4), 'max_last'); + `; + const { plots } = await pineTS.run(code); + // 5th largest = minimum = 10 + expect(plots['max_last'].data[0].value).toBe(10); + }); + + it('should handle empty array', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.new_float(0); + plotchar(array.max(arr), 'max'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max'].data[0].value).toBeNaN(); + }); + + it('should handle large array (100 elements)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let arr = array.new_float(0); + for (let i = 1; i <= 100; i++) { + array.push(arr, i); + } + plotchar(array.max(arr), 'max'); + plotchar(array.max(arr, 99), 'min_via_nth'); + `; + const { plots } = await pineTS.run(code); + expect(plots['max'].data[0].value).toBe(100); + expect(plots['min_via_nth'].data[0].value).toBe(1); + }); + }); + + // ── min/max combined usage (like SuperTrend AI K-means) ── + + describe('min/max in K-means pattern', () => { + it('should find index of minimum distance (dist.indexof(dist.min()))', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, math, plotchar } = context.pine; + let dist = array.from(5.0, 2.0, 8.0); + let minVal = array.min(dist); + let idx = array.indexof(dist, minVal); + plotchar(minVal, 'minVal'); + plotchar(idx, 'idx'); + `; + const { plots } = await pineTS.run(code); + expect(plots['minVal'].data[0].value).toBe(2); + expect(plots['idx'].data[0].value).toBe(1); + }); + + it('should handle distance array with duplicate minimums', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` + const { array, plotchar } = context.pine; + let dist = array.from(3.0, 1.0, 1.0); + let minVal = array.min(dist); + let idx = array.indexof(dist, minVal); + plotchar(minVal, 'minVal'); + plotchar(idx, 'idx'); + `; + const { plots } = await pineTS.run(code); + expect(plots['minVal'].data[0].value).toBe(1); + // indexof returns first occurrence + expect(plots['idx'].data[0].value).toBe(1); + }); + }); +}); From f6d3890be60dc4c34787dc06a5e4cc19fdf94ba0 Mon Sep 17 00:00:00 2001 From: alaa-eddine Date: Thu, 5 Mar 2026 16:25:31 +0100 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cab5d62..6670613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Change Log +## [0.9.2] - 2026-03-05 - Gradient Fill, Matrix Inverse, Array Optimizations & Plot Fixes + +### Added + +- **Gradient Fill (`fill()`)**: Added support for Pine Script's gradient fill signature — `fill(plot1, plot2, top_value, bottom_value, top_color, bottom_color)`. The `FillHelper` now detects the gradient form by checking whether the third argument is a number (gradient) or a string/color (simple fill), and stores per-bar `top_value`/`bottom_value`/`top_color`/`bottom_color` data for rendering. + +### Fixed + +- **`matrix.inv()` — Full NxN Support**: Rewrote `matrix.inv()` from a 2×2-only implementation to a general Gauss-Jordan elimination with partial pivoting, supporting any square matrix of arbitrary size. Singular matrices (pivot < 1e-14) correctly return a NaN matrix. +- **`matrix.pinv()` — Real Pseudoinverse**: Rewrote `matrix.pinv()` from a placeholder stub to a correct Moore-Penrose pseudoinverse: square matrices use `inv()`, tall matrices (m > n) use `(AᵀA)⁻¹Aᵀ`, and wide matrices (m < n) use `Aᵀ(AAᵀ)⁻¹`. +- **`array.min()` / `array.max()` Performance**: Added an O(N) fast path for the common `nth=0` case (find absolute min/max) instead of always sorting the full array O(N log N). Sorting is still used only when `nth > 0`. +- **`array.median()` Performance**: Replaced the `for...of` copy + complex sort comparator with a direct index loop and simple numeric sort for faster execution. +- **`array.percentile_linear_interpolation()` Performance**: Validate and copy the array in a single pass (eliminating the separate `validValues` allocation), then sort once. +- **`array.percentile_nearest_rank()` Performance**: Same single-pass validate-and-copy optimization as `percentile_linear_interpolation`. +- **`isPlot()` with Undefined Title**: Fixed the `isPlot()` helper check to accept plots that have no `title` property but do have a `_plotKey` property (e.g., plots created via `fill()` or accessed by callsite ID). Previously these were not recognised as plot objects, causing `fill()` to misidentify its arguments (contribution by @dcaoyuan, [#142](https://github.com/QuantForgeOrg/PineTS/issues/142)). + ## [0.9.1] - 2026-03-04 - Enum Values, ATR/DMI/Supertrend Fixes, UDT & Transpiler Improvements ### Added diff --git a/package.json b/package.json index cdc9a46..93ff9b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinets", - "version": "0.9.1", + "version": "0.9.2", "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.", "keywords": [ "Pine Script",