diff --git a/.github/badges/api-types.svg b/.github/badges/api-types.svg index 3c471c5..a5a7d61 100644 --- a/.github/badges/api-types.svg +++ b/.github/badges/api-types.svg @@ -1,14 +1,14 @@ - - types: 177/180 (98%) + + types: 180/180 (100%) - + \ No newline at end of file diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index 41fc477..b5b41ec 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1,20 +1,20 @@ - - test coverage: 84.4% - + + test coverage: 84.5% + - - + + - + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 31cfe82..1ca8954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Change Log +## [0.9.9] - 2026-04-02 - Drawing Setters, NAMESPACES_LIKE Subscripts & force_overlay Sync + +### Fixed + +- **NAMESPACES_LIKE subscripts (transpiler)**: Subscripts on dual-use builtins (`time[1]`, `time_close[1]`, etc.) now emit **`$.get(name.__value, n)`** instead of **`name.__value[n]`**, so lookback matches Pine Script / forward-array Series semantics. +- **Box / line coordinate setters**: `set_lefttop`, `set_rightbottom`, `set_xy1`, `set_xy2`, `set_left`, `set_right`, and related setters on **box** and **line** helpers now call **`_resolve()`** so Series-derived coordinates unwrap the same way as **`new()`** constructors. +- **`force_overlay` on drawing objects**: **`syncToPlot()`** in **BoxHelper**, **LineHelper**, and **LabelHelper** routes **`force_overlay=true`** objects into **separate overlay plots** so chart integrations can place them on the main price pane. + +### Added + +- **Tests**: `box-setters-resolve`, namespace subscript transpiler coverage, and **gradient `fill()`** cases. + +--- + ## [0.9.8] - 2026-03-27 - TA Cross/CrossUnder, Matrix·Vector, Plot Serialization & Input Fixes ### Fixed diff --git a/docs/api-coverage/pinescript-v6/types.json b/docs/api-coverage/pinescript-v6/types.json index 27f7c8d..d6fa189 100644 --- a/docs/api-coverage/pinescript-v6/types.json +++ b/docs/api-coverage/pinescript-v6/types.json @@ -21,9 +21,9 @@ "adjustment.splits": true }, "alert": { - "alert.freq_all": false, - "alert.freq_once_per_bar": false, - "alert.freq_once_per_bar_close": false + "alert.freq_all": true, + "alert.freq_once_per_bar": true, + "alert.freq_once_per_bar_close": true }, "backadjustment": { "backadjustment.inherit": true, diff --git a/docs/api-coverage/types.md b/docs/api-coverage/types.md index e74dacc..072b410 100644 --- a/docs/api-coverage/types.md +++ b/docs/api-coverage/types.md @@ -290,9 +290,9 @@ parent: API Coverage | Function | Status | Description | | ------------------------------- | ------ | ---------------------------------- | -| `alert.freq_all` | | Alert frequency all | -| `alert.freq_once_per_bar` | | Alert frequency once per bar | -| `alert.freq_once_per_bar_close` | | Alert frequency once per bar close | +| `alert.freq_all` | ✅ | Alert frequency all | +| `alert.freq_once_per_bar` | ✅ | Alert frequency once per bar | +| `alert.freq_once_per_bar_close` | ✅ | Alert frequency once per bar close | ### Backadjustment diff --git a/package.json b/package.json index 28d4e0a..112776b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinets", - "version": "0.9.8", + "version": "0.9.9", "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", diff --git a/src/namespaces/box/BoxHelper.ts b/src/namespaces/box/BoxHelper.ts index a752afc..ac45b36 100644 --- a/src/namespaces/box/BoxHelper.ts +++ b/src/namespaces/box/BoxHelper.ts @@ -45,11 +45,27 @@ export class BoxHelper { public syncToPlot() { this._ensurePlotsEntry(); const time = this.context.marketData[0]?.openTime || 0; + const allPlotData = this._boxes.map(bx => bx.toPlotData()); + + // Split force_overlay objects into a separate overlay plot (renders on main chart pane) + const regular = allPlotData.filter((b: any) => !b.force_overlay); + const overlay = allPlotData.filter((b: any) => b.force_overlay); + this.context.plots['__boxes__'].data = [{ time, - value: this._boxes.map(bx => bx.toPlotData()), + value: regular, options: { style: 'drawing_box' }, }]; + + if (overlay.length > 0) { + this.context.plots['__boxes_overlay__'] = { + title: '__boxes_overlay__', + data: [{ time, value: overlay, options: { style: 'drawing_box' } }], + options: { style: 'drawing_box', overlay: true }, + }; + } else { + delete this.context.plots['__boxes_overlay__']; + } } private _resolvePoint(point: ChartPointObject): { x: number; xloc: string } { @@ -172,32 +188,32 @@ export class BoxHelper { // --- Coordinate setters --- set_left(id: BoxObject, left: number): void { - if (id && !id._deleted) id.left = left; + if (id && !id._deleted) id.left = this._resolve(left); } set_right(id: BoxObject, right: number): void { - if (id && !id._deleted) id.right = right; + if (id && !id._deleted) id.right = this._resolve(right); } set_top(id: BoxObject, top: number): void { - if (id && !id._deleted) id.top = top; + if (id && !id._deleted) id.top = this._resolve(top); } set_bottom(id: BoxObject, bottom: number): void { - if (id && !id._deleted) id.bottom = bottom; + if (id && !id._deleted) id.bottom = this._resolve(bottom); } set_lefttop(id: BoxObject, left: number, top: number): void { if (id && !id._deleted) { - id.left = left; - id.top = top; + id.left = this._resolve(left); + id.top = this._resolve(top); } } set_rightbottom(id: BoxObject, right: number, bottom: number): void { if (id && !id._deleted) { - id.right = right; - id.bottom = bottom; + id.right = this._resolve(right); + id.bottom = this._resolve(bottom); } } @@ -221,9 +237,9 @@ export class BoxHelper { set_xloc(id: BoxObject, left: number, right: number, xloc: string): void { if (id && !id._deleted) { - id.left = left; - id.right = right; - id.xloc = xloc; + id.left = this._resolve(left); + id.right = this._resolve(right); + id.xloc = this._resolve(xloc); } } diff --git a/src/namespaces/label/LabelHelper.ts b/src/namespaces/label/LabelHelper.ts index 0200849..df268f6 100644 --- a/src/namespaces/label/LabelHelper.ts +++ b/src/namespaces/label/LabelHelper.ts @@ -43,17 +43,28 @@ export class LabelHelper { public syncToPlot() { this._ensurePlotsEntry(); - // Store ALL labels as a single array value at the first bar's time. - // Using a live reference so setter mutations are reflected automatically. - // Multiple label objects at the same bar would overwrite each other - // in QFChart's sparse data array, so we aggregate them into one entry - // (same approach as LineHelper._syncToPlot). const time = this.context.marketData[0]?.openTime || 0; + const allPlotData = this._labels.map(lbl => lbl.toPlotData()); + + // Split force_overlay objects into a separate overlay plot (renders on main chart pane) + const regular = allPlotData.filter((l: any) => !l.force_overlay); + const overlay = allPlotData.filter((l: any) => l.force_overlay); + this.context.plots['__labels__'].data = [{ time, - value: this._labels.map(lbl => lbl.toPlotData()), + value: regular, options: { style: 'label' }, }]; + + if (overlay.length > 0) { + this.context.plots['__labels_overlay__'] = { + title: '__labels_overlay__', + data: [{ time, value: overlay, options: { style: 'label' } }], + options: { style: 'label', overlay: true }, + }; + } else { + delete this.context.plots['__labels_overlay__']; + } } /** diff --git a/src/namespaces/line/LineHelper.ts b/src/namespaces/line/LineHelper.ts index 457a87f..732a9f7 100644 --- a/src/namespaces/line/LineHelper.ts +++ b/src/namespaces/line/LineHelper.ts @@ -41,16 +41,28 @@ export class LineHelper { public syncToPlot() { this._ensurePlotsEntry(); - // Store ALL lines as a single array value at the first bar's time. - // Using a live reference so setter mutations are reflected automatically. - // Multiple drawing objects at the same bar would overwrite each other - // in QFChart's sparse data array, so we aggregate them into one entry. const time = this.context.marketData[0]?.openTime || 0; + const allPlotData = this._lines.map(ln => ln.toPlotData()); + + // Split force_overlay objects into a separate overlay plot (renders on main chart pane) + const regular = allPlotData.filter((l: any) => !l.force_overlay); + const overlay = allPlotData.filter((l: any) => l.force_overlay); + this.context.plots['__lines__'].data = [{ time, - value: this._lines.map(ln => ln.toPlotData()), + value: regular, options: { style: 'drawing_line' }, }]; + + if (overlay.length > 0) { + this.context.plots['__lines_overlay__'] = { + title: '__lines_overlay__', + data: [{ time, value: overlay, options: { style: 'drawing_line' } }], + options: { style: 'drawing_line', overlay: true }, + }; + } else { + delete this.context.plots['__lines_overlay__']; + } } private _resolvePoint(point: ChartPointObject): { x: number; xloc: string } { @@ -161,32 +173,32 @@ export class LineHelper { // --- Setter methods --- set_x1(id: LineObject, x: number): void { - if (id && !id._deleted) id.x1 = x; + if (id && !id._deleted) id.x1 = this._resolve(x); } set_y1(id: LineObject, y: number): void { - if (id && !id._deleted) id.y1 = y; + if (id && !id._deleted) id.y1 = this._resolve(y); } set_x2(id: LineObject, x: number): void { - if (id && !id._deleted) id.x2 = x; + if (id && !id._deleted) id.x2 = this._resolve(x); } set_y2(id: LineObject, y: number): void { - if (id && !id._deleted) id.y2 = y; + if (id && !id._deleted) id.y2 = this._resolve(y); } set_xy1(id: LineObject, x: number, y: number): void { if (id && !id._deleted) { - id.x1 = x; - id.y1 = y; + id.x1 = this._resolve(x); + id.y1 = this._resolve(y); } } set_xy2(id: LineObject, x: number, y: number): void { if (id && !id._deleted) { - id.x2 = x; - id.y2 = y; + id.x2 = this._resolve(x); + id.y2 = this._resolve(y); } } diff --git a/src/transpiler/transformers/ExpressionTransformer.ts b/src/transpiler/transformers/ExpressionTransformer.ts index fc128eb..c4d952c 100644 --- a/src/transpiler/transformers/ExpressionTransformer.ts +++ b/src/transpiler/transformers/ExpressionTransformer.ts @@ -549,6 +549,23 @@ function transformOperand(node: any, scopeManager: ScopeManager, namespace: stri NAMESPACES_LIKE.includes(node.object.name) && scopeManager.isContextBound(node.object.name); + // For computed access on NAMESPACES_LIKE identifiers (e.g. time[1], close[2]), + // produce $.get(identifier.__value, offset) instead of identifier.__value[offset]. + const isNamespaceSubscript = node.computed && + node.object.type === 'Identifier' && + NAMESPACES_LIKE.includes(node.object.name) && + scopeManager.isContextBound(node.object.name); + + if (isNamespaceSubscript) { + const valueExpr = { + type: 'MemberExpression', + object: { type: 'Identifier', name: node.object.name }, + property: { type: 'Identifier', name: '__value' }, + computed: false, + }; + return ASTFactory.createGetCall(valueExpr, node.property); + } + // Handle array access const transformedObject = (node.object.type === 'Identifier' && !isNamespacePropAccess) ? transformIdentifierForParam(node.object, scopeManager) diff --git a/tests/namespaces/box/box-setters-resolve.test.ts b/tests/namespaces/box/box-setters-resolve.test.ts new file mode 100644 index 0000000..a3d1227 --- /dev/null +++ b/tests/namespaces/box/box-setters-resolve.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest'; +import { PineTS, Provider } from 'index'; + +describe('Drawing object force_overlay split', () => { + it('box.new with force_overlay=true creates a separate __boxes_overlay__ plot', async () => { + const code = ` +//@version=5 +indicator("Force overlay box", overlay=false) +// Non-overlay box (stays in indicator pane) +var b1 = box.new(0, 100, 10, 50) +// Overlay box (should go to main chart pane) +var b2 = box.new(0, 60000, 10, 50000, force_overlay=true) +plot(close, "price") +`; + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + const { plots } = await pineTS.run(code); + + // Regular boxes plot should exist with the non-overlay box + expect(plots['__boxes__']).toBeDefined(); + const regularBoxes = plots['__boxes__'].data[0].value; + const activeRegular = regularBoxes.filter((b: any) => b && !b._deleted && !b.force_overlay); + expect(activeRegular.length).toBe(1); + + // Overlay boxes plot should exist with the force_overlay box + expect(plots['__boxes_overlay__']).toBeDefined(); + expect(plots['__boxes_overlay__'].options.overlay).toBe(true); + const overlayBoxes = plots['__boxes_overlay__'].data[0].value; + const activeOverlay = overlayBoxes.filter((b: any) => b && !b._deleted); + expect(activeOverlay.length).toBe(1); + expect(activeOverlay[0].force_overlay).toBe(true); + }); + + it('line.new with force_overlay=true creates a separate __lines_overlay__ plot', async () => { + const code = ` +//@version=5 +indicator("Force overlay line", overlay=false) +var ln1 = line.new(0, 100, 10, 50) +var ln2 = line.new(0, 60000, 10, 50000, force_overlay=true) +plot(close, "price") +`; + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + const { plots } = await pineTS.run(code); + + expect(plots['__lines__']).toBeDefined(); + expect(plots['__lines_overlay__']).toBeDefined(); + expect(plots['__lines_overlay__'].options.overlay).toBe(true); + + const overlayLines = plots['__lines_overlay__'].data[0].value; + const activeOverlay = overlayLines.filter((l: any) => l && !l._deleted); + expect(activeOverlay.length).toBe(1); + expect(activeOverlay[0].force_overlay).toBe(true); + }); + + it('label.new with force_overlay=true creates a separate __labels_overlay__ plot', async () => { + const code = ` +//@version=5 +indicator("Force overlay label", overlay=false) +if barstate.islast + label.new(bar_index, 100, "Indicator label") + label.new(bar_index, close, "Overlay label", force_overlay=true) +plot(close, "price") +`; + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + const { plots } = await pineTS.run(code); + + expect(plots['__labels__']).toBeDefined(); + expect(plots['__labels_overlay__']).toBeDefined(); + expect(plots['__labels_overlay__'].options.overlay).toBe(true); + + const overlayLabels = plots['__labels_overlay__'].data[0].value; + const activeOverlay = overlayLabels.filter((l: any) => l && !l._deleted); + expect(activeOverlay.length).toBe(1); + expect(activeOverlay[0].force_overlay).toBe(true); + }); + + it('no overlay plot created when no force_overlay objects exist', async () => { + const code = ` +//@version=5 +indicator("No force overlay", overlay=false) +var b = box.new(0, 100, 10, 50) +plot(close, "price") +`; + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + const { plots } = await pineTS.run(code); + + expect(plots['__boxes__']).toBeDefined(); + expect(plots['__boxes_overlay__']).toBeUndefined(); + }); +}); + +describe('Box/Line setters resolve Series values', () => { + + it('box.set_lefttop with time-based expressions produces valid plot data', async () => { + // Bug: set_lefttop/set_rightbottom didn't call _resolve(), so Series-derived + // values (e.g. time + offset) were stored as raw Series objects instead of numbers. + const code = ` +//@version=5 +indicator("Box setters resolve", overlay=true) +var b = box.new(na, na, na, na, xloc=xloc.bar_time) +if barstate.islast + barDur = int(math.max(time - time[1], 0)) + leftT = time + 2 * barDur + rightT = leftT + 6 * barDur + box.set_lefttop(b, leftT, high) + box.set_rightbottom(b, rightT, low) +`; + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + const { plots } = await pineTS.run(code); + + expect(plots['__boxes__']).toBeDefined(); + const boxData = plots['__boxes__'].data; + expect(boxData.length).toBeGreaterThan(0); + + // Find the active (non-deleted) box + const boxes = Array.isArray(boxData[0].value) ? boxData[0].value : [boxData[0].value]; + const activeBox = boxes.find((b: any) => b && !b._deleted); + expect(activeBox).toBeDefined(); + + // Coordinates must be valid numbers (not NaN, not null, not objects) + expect(typeof activeBox.left).toBe('number'); + expect(typeof activeBox.right).toBe('number'); + expect(typeof activeBox.top).toBe('number'); + expect(typeof activeBox.bottom).toBe('number'); + expect(isNaN(activeBox.left)).toBe(false); + expect(isNaN(activeBox.right)).toBe(false); + expect(isNaN(activeBox.top)).toBe(false); + expect(isNaN(activeBox.bottom)).toBe(false); + + // xloc should be 'bt' (bar_time) + expect(activeBox.xloc).toBe('bt'); + + // left should be a future timestamp (greater than any market data time) + expect(activeBox.left).toBeGreaterThan(1000000000000); // reasonable timestamp + expect(activeBox.right).toBeGreaterThan(activeBox.left); // right > left + }); + + it('line.set_xy1/set_xy2 with time-based expressions produces valid plot data', async () => { + const code = ` +//@version=5 +indicator("Line setters resolve", overlay=true) +var ln = line.new(na, na, na, na, xloc=xloc.bar_time) +if barstate.islast + barDur = int(math.max(time - time[1], 0)) + midT = time + 4 * barDur + line.set_xy1(ln, time, high) + line.set_xy2(ln, midT, high) +`; + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + const { plots } = await pineTS.run(code); + + expect(plots['__lines__']).toBeDefined(); + const lineData = plots['__lines__'].data; + expect(lineData.length).toBeGreaterThan(0); + + const lines = Array.isArray(lineData[0].value) ? lineData[0].value : [lineData[0].value]; + const activeLine = lines.find((l: any) => l && !l._deleted); + expect(activeLine).toBeDefined(); + + // All coordinates must be valid numbers + expect(typeof activeLine.x1).toBe('number'); + expect(typeof activeLine.x2).toBe('number'); + expect(typeof activeLine.y1).toBe('number'); + expect(typeof activeLine.y2).toBe('number'); + expect(isNaN(activeLine.x1)).toBe(false); + expect(isNaN(activeLine.x2)).toBe(false); + expect(isNaN(activeLine.y1)).toBe(false); + expect(isNaN(activeLine.y2)).toBe(false); + + // x2 should be in the future relative to x1 + expect(activeLine.x2).toBeGreaterThan(activeLine.x1); + }); + + it('box.set_left/set_right/set_top/set_bottom resolve individually', async () => { + const code = ` +//@version=5 +indicator("Box individual setters", overlay=true) +var b = box.new(0, 0, 0, 0) +if barstate.islast + box.set_left(b, bar_index - 10) + box.set_right(b, bar_index) + box.set_top(b, high) + box.set_bottom(b, low) +`; + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + const { plots } = await pineTS.run(code); + + const boxes = Array.isArray(plots['__boxes__'].data[0].value) + ? plots['__boxes__'].data[0].value + : [plots['__boxes__'].data[0].value]; + const b = boxes.find((b: any) => b && !b._deleted); + + expect(typeof b.left).toBe('number'); + expect(typeof b.right).toBe('number'); + expect(typeof b.top).toBe('number'); + expect(typeof b.bottom).toBe('number'); + expect(isNaN(b.left)).toBe(false); + expect(isNaN(b.right)).toBe(false); + expect(b.right).toBeGreaterThan(b.left); + expect(b.top).toBeGreaterThan(b.bottom); + }); +}); diff --git a/tests/namespaces/fill/gradient-fill.test.ts b/tests/namespaces/fill/gradient-fill.test.ts new file mode 100644 index 0000000..cb115d0 --- /dev/null +++ b/tests/namespaces/fill/gradient-fill.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { PineTS, Provider } from 'index'; + +describe('Gradient fill (top_value/bottom_value)', () => { + const makePineTS = () => new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + + it('gradient fill stores per-bar top_value and bottom_value in options', async () => { + const code = ` +//@version=5 +indicator("Gradient fill test") +osc = ta.rsi(close, 14) - 50 +p1 = plot(osc, "Signal") +p2 = plot(0, "Zero") +fill(p1, p2, top_value=math.max(osc, 0), bottom_value=math.min(osc, 0), + top_color=color.new(color.green, 80), bottom_color=color.new(color.red, 80), + title="Gradient") +`; + const pineTS = makePineTS(); + const { plots } = await pineTS.run(code); + + // Gradient fill plot should exist and be marked as gradient + const gradientPlot = Object.values(plots).find((p: any) => + p.options?.style === 'fill' && p.options?.gradient === true + ) as any; + expect(gradientPlot).toBeDefined(); + expect(gradientPlot.options.gradient).toBe(true); + + // Each data point should have top_value, bottom_value, top_color, bottom_color + const dataPoints = gradientPlot.data.filter((d: any) => d.options); + expect(dataPoints.length).toBeGreaterThan(0); + + for (const d of dataPoints) { + expect(d.options).toHaveProperty('top_value'); + expect(d.options).toHaveProperty('bottom_value'); + expect(d.options).toHaveProperty('top_color'); + expect(d.options).toHaveProperty('bottom_color'); + } + }); + + it('gradient fill with conditional na hides fill on inactive bars', async () => { + // When top_value/bottom_value are na, the fill should not render. + // This tests the glow breakout pattern: fill only when signal > threshold. + const code = ` +//@version=5 +indicator("Conditional gradient fill") +osc = ta.rsi(close, 14) - 50 +thresh = 20.0 +isOver = osc > thresh +p1 = plot(osc, "Signal") +p2 = plot(thresh, "Threshold") +fill(p1, p2, + top_value=isOver ? osc : na, + bottom_value=isOver ? thresh : na, + top_color=color.new(color.green, 40), + bottom_color=color.new(color.green, 90), + title="Glow") +`; + const pineTS = makePineTS(); + const { plots } = await pineTS.run(code); + + const glowPlot = Object.values(plots).find((p: any) => + p.options?.style === 'fill' && p.options?.gradient === true + ) as any; + expect(glowPlot).toBeDefined(); + + const dataPoints = glowPlot.data.filter((d: any) => d.options); + expect(dataPoints.length).toBeGreaterThan(0); + + // Some bars should have na (NaN) top_value when signal < threshold + const naPoints = dataPoints.filter((d: any) => + d.options.top_value === null || d.options.top_value === undefined || + (typeof d.options.top_value === 'number' && isNaN(d.options.top_value)) + ); + const validPoints = dataPoints.filter((d: any) => + typeof d.options.top_value === 'number' && !isNaN(d.options.top_value) + ); + + // Should have a mix of na and valid points (not all visible or all hidden) + expect(naPoints.length).toBeGreaterThan(0); + expect(validPoints.length).toBeGreaterThan(0); + + // Valid points should have top_value > bottom_value (signal > threshold) + for (const d of validPoints) { + if (typeof d.options.bottom_value === 'number' && !isNaN(d.options.bottom_value)) { + expect(d.options.top_value).toBeGreaterThanOrEqual(d.options.bottom_value); + } + } + }); + + it('base heatmap gradient uses max/min clamping for top_value/bottom_value', async () => { + const code = ` +//@version=5 +indicator("Base heatmap gradient") +osc = ta.rsi(close, 14) - 50 +p1 = plot(osc, "Signal") +p2 = plot(0, "Zero") +fill(p1, p2, top_value=math.max(osc, 0), bottom_value=math.min(osc, 0), + top_color=color.new(color.green, 85), bottom_color=color.new(color.red, 85), + title="Heatmap") +`; + const pineTS = makePineTS(); + const { plots } = await pineTS.run(code); + + const heatmapPlot = Object.values(plots).find((p: any) => + p.options?.style === 'fill' && p.options?.gradient === true + ) as any; + expect(heatmapPlot).toBeDefined(); + + const dataPoints = heatmapPlot.data.filter((d: any) => + d.options && typeof d.options.top_value === 'number' && !isNaN(d.options.top_value) + ); + + for (const d of dataPoints) { + // top_value = max(osc, 0) → always >= 0 + expect(d.options.top_value).toBeGreaterThanOrEqual(0); + // bottom_value = min(osc, 0) → always <= 0 + expect(d.options.bottom_value).toBeLessThanOrEqual(0); + } + }); +}); diff --git a/tests/transpiler/namespace-subscript.test.ts b/tests/transpiler/namespace-subscript.test.ts new file mode 100644 index 0000000..656d973 --- /dev/null +++ b/tests/transpiler/namespace-subscript.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { PineTS, Provider } from 'index'; + +describe('NAMESPACES_LIKE subscript access (time[n], close[n])', () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2018-12-10').getTime(), new Date('2019-06-01').getTime()); + + it('time[1] produces a valid timestamp (not NaN)', async () => { + // Bug: time[1] was transpiled to time.__value[1] instead of $.get(time.__value, 1), + // producing NaN because direct array indexing doesn't use the Series offset pointer. + const code = ` +//@version=5 +indicator("time subscript test") +diff = time - time[1] +plot(diff, "diff") +plot(time[1], "time1") +`; + const { plots } = await pineTS.run(code); + + const diffData = plots['diff'].data; + const time1Data = plots['time1'].data; + + // time[1] should be a valid timestamp (not NaN/null) after the first bar + const validTime1 = time1Data.filter(d => d.value !== null && !isNaN(d.value)); + expect(validTime1.length).toBeGreaterThan(0); + + // time - time[1] should be a positive number (bar duration) after the first bar + const validDiff = diffData.filter(d => d.value !== null && !isNaN(d.value) && d.value > 0); + expect(validDiff.length).toBeGreaterThan(0); + + // On a weekly chart, bar duration should be ~604800000ms (7 days) + const weekMs = 7 * 24 * 60 * 60 * 1000; + for (const d of validDiff) { + expect(d.value).toBeCloseTo(weekMs, -4); // within ~10s tolerance + } + }); + + it('time[1] in arithmetic expression inside function call', async () => { + // Tests the exact pattern from HTF Candle Projections indicator: + // int barDuration = int(math.max(time - time[1], 0)) + const code = ` +//@version=5 +indicator("time in math.max") +barDur = int(math.max(time - time[1], 0)) +futTime = time + 2 * barDur +plot(barDur, "barDur") +plot(futTime, "futTime") +`; + const { plots } = await pineTS.run(code); + + const barDurData = plots['barDur'].data; + const futTimeData = plots['futTime'].data; + + // barDur should be a valid positive integer after bar 0 + const validBarDur = barDurData.filter(d => d.value !== null && !isNaN(d.value) && d.value > 0); + expect(validBarDur.length).toBeGreaterThan(0); + + // futTime should be a valid future timestamp (larger than time) + const validFutTime = futTimeData.filter(d => d.value !== null && !isNaN(d.value)); + expect(validFutTime.length).toBeGreaterThan(0); + // futTime = time + 2*barDur, should be > time on every valid bar + for (const d of validFutTime) { + expect(d.value).toBeGreaterThan(0); + } + }); + + it('close[n] with variable offset produces valid values', async () => { + // Tests NAMESPACES_LIKE subscript with a variable index (not just literal) + const code = ` +//@version=5 +indicator("close with variable offset") +lookback = 3 +val = close[lookback] +diff = close - val +plot(val, "val") +plot(diff, "diff") +`; + const { plots } = await pineTS.run(code); + + const valData = plots['val'].data; + // close[3] should be a valid number after bar 3 + const validVals = valData.filter(d => d.value !== null && !isNaN(d.value)); + expect(validVals.length).toBeGreaterThan(0); + }); +});