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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/Context.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
89 changes: 69 additions & 20 deletions src/namespaces/Plots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,31 +442,80 @@ export class FillHelper {
}
any(...args) {
const callsiteId = extractCallsiteId(args);
const _parsed = parseArgsForPineParams<FillOptions>(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<FillOptions>(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,
};
}
}
}
}
16 changes: 14 additions & 2 deletions src/namespaces/array/methods/max.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

17 changes: 6 additions & 11 deletions src/namespaces/array/methods/median.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
16 changes: 14 additions & 2 deletions src/namespaces/array/methods/min.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

22 changes: 12 additions & 10 deletions src/namespaces/array/methods/percentile_linear_interpolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}
15 changes: 6 additions & 9 deletions src/namespaces/array/methods/percentile_nearest_rank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
for (let i = 0; i < totalCount; i++) {
const val = Number(array[i]);
if (!isNaN(val) && val !== null && val !== undefined) {
validValues.push(val);
}
Expand All @@ -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];
}

Expand All @@ -39,5 +38,3 @@ export function percentile_nearest_rank(context: any) {
return validValues[rank - 1];
};
}


86 changes: 72 additions & 14 deletions src/namespaces/matrix/methods/inv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading