diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index a51afef4..41fc477f 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1,20 +1,20 @@ - - test coverage: 84.3% - + + test coverage: 84.4% + - - + + - + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f32a579..31cfe822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Change Log +## [0.9.8] - 2026-03-27 - TA Cross/CrossUnder, Matrix·Vector, Plot Serialization & Input Fixes + +### Fixed + +- **`ta.crossover` / `ta.crossunder`**: Boundary comparison now uses inclusive `<=` / `>=` where TradingView expects equality at the crossing bar (replaces strict `<` / `>`). Verified against TradingView reference logs. +- **`matrix.mult` (vector operand)**: Multiplying a matrix by a row/column vector now returns a **`PineArrayObject`** instead of a **`PineMatrixObject`**, matching Pine Script semantics and fixing polyline-style indicators (e.g. Spline Quantile Regression). +- **`plotchar` Signature**: Corrected `PLOTCHAR_SIGNATURE` so the **`char`** argument is in the proper parameter slot for dynamic `plotchar` calls. +- **`TYPE_CHECK.color`**: Accepts **Series-wrapped** color values so colors passed through variables are not rejected and lost at runtime. +- **`color.new` / `color.rgb`**: NaN / invalid transparency no longer produces malformed hex strings (e.g. `#787b86NAN00`). +- **Bool `input`**: Fixed bool input default/coercion edge cases. +- **UDT Return from User Functions**: Fixed user-defined functions that return a UDT instance. +- **Drawing Object Serialization / `context.plot`**: Plot serialization avoids **circular references** when drawing objects are present. + +--- + ## [0.9.7] - 2026-03-23 - Alerts, Fill & Drawing Fixes, OOB Warnings (TV-Aligned) ### Added diff --git a/package.json b/package.json index bd37a66c..28d4e0a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinets", - "version": "0.9.7", + "version": "0.9.8", "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/Context.class.ts b/src/Context.class.ts index 3ef67d25..e041221f 100644 --- a/src/Context.class.ts +++ b/src/Context.class.ts @@ -73,8 +73,8 @@ export class Context { public lang: any; public length: number = 0; - /** References to drawing helpers for streaming rollback */ - public _drawingHelpers: { rollbackFromBar(barIdx: number): void }[] = []; + /** References to drawing helpers for streaming rollback and plot sync */ + public _drawingHelpers: { rollbackFromBar(barIdx: number): void; syncToPlot?(): void }[] = []; // Combined namespace and core functions - the default way to access everything public pine: { @@ -444,9 +444,6 @@ export class Context { get: () => polylineHelper.all, }); - // Register drawing helpers for streaming rollback - this._drawingHelpers = [labelHelper, lineHelper, boxHelper, linefillHelper, polylineHelper]; - // table namespace const tableHelper = new TableHelper(this); this.bindContextObject( @@ -482,6 +479,9 @@ export class Context { get: () => tableHelper.all, }); + // Register all drawing helpers for streaming rollback and plot sync + this._drawingHelpers = [labelHelper, lineHelper, boxHelper, linefillHelper, polylineHelper, tableHelper]; + // color namespace const colorHelper = new PineColor(this); this.bindContextObject( diff --git a/src/PineTS.class.ts b/src/PineTS.class.ts index b3ba0c89..16663421 100644 --- a/src/PineTS.class.ts +++ b/src/PineTS.class.ts @@ -783,6 +783,13 @@ export class PineTS { context.result.push(result); } + // Sync drawing object plots after all mutations for this bar. + // Serializes the current state of labels/lines/boxes/etc. into plain objects + // so that context.plots contains safe, JSON-serializable data. + for (const helper of context._drawingHelpers) { + if (helper.syncToPlot) helper.syncToPlot(); + } + //shift context const shiftVariables = (container: any) => { for (let ctxVarName of contextVarNames) { diff --git a/src/namespaces/Plots.ts b/src/namespaces/Plots.ts index 738b381f..92f2f93e 100644 --- a/src/namespaces/Plots.ts +++ b/src/namespaces/Plots.ts @@ -8,6 +8,20 @@ const PLOT_SIGNATURE = [ 'join', 'editable', 'show_last', 'display', 'format', 'precision', 'force_overlay', ]; +//prettier-ignore +const PLOTCHAR_SIGNATURE = [ + 'series', 'title', 'char', 'location', 'color', 'offset', 'text', 'textcolor', + 'editable', 'size', 'show_last', 'display', 'format', 'precision', 'force_overlay', +]; + +//prettier-ignore +const PLOTCHAR_ARGS_TYPES = { + series: 'series', title: 'string', char: 'string', location: 'string', + color: 'color', offset: 'number', text: 'string', textcolor: 'color', + editable: 'boolean', size: 'string', show_last: 'number', display: 'string', + format: 'string', precision: 'number', force_overlay: 'boolean', +}; + //prettier-ignore const PLOT_SHAPE_SIGNATURE = [ 'series', 'title', 'style', 'location', 'color', 'offset', 'text', 'textcolor', @@ -193,7 +207,7 @@ export class PlotHelper { //in the current implementation, plot functions are only used to collect data for the plots array and map it to the market data plotchar(...args) { const callsiteId = extractCallsiteId(args); - const _parsed = parseArgsForPineParams(args, PLOT_SIGNATURE, PLOT_ARGS_TYPES); + const _parsed = parseArgsForPineParams(args, PLOTCHAR_SIGNATURE, PLOTCHAR_ARGS_TYPES); const { series, title, ...others } = _parsed; const options = this.extractPlotOptions(others); const plotKey = this._resolvePlotKey(title, callsiteId); @@ -208,6 +222,14 @@ export class PlotHelper { title, time: this.context.marketData[this.context.idx].openTime, value: value, + options: { + char: options.char, + color: options.color, + textcolor: options.textcolor, + location: options.location, + size: options.size, + offset: options.offset, + }, }); return this.context.plots[plotKey]; } diff --git a/src/namespaces/box/BoxHelper.ts b/src/namespaces/box/BoxHelper.ts index 5a84c7ec..a752afcb 100644 --- a/src/namespaces/box/BoxHelper.ts +++ b/src/namespaces/box/BoxHelper.ts @@ -42,12 +42,12 @@ export class BoxHelper { } } - private _syncToPlot() { + public syncToPlot() { this._ensurePlotsEntry(); const time = this.context.marketData[0]?.openTime || 0; this.context.plots['__boxes__'].data = [{ time, - value: this._boxes, + value: this._boxes.map(bx => bx.toPlotData()), options: { style: 'drawing_box' }, }]; } @@ -117,7 +117,7 @@ export class BoxHelper { b._helper = this; b._createdAtBar = this.context.idx; this._boxes.push(b); - this._syncToPlot(); + this.syncToPlot(); return b; } @@ -309,7 +309,7 @@ export class BoxHelper { b._helper = this; b._createdAtBar = this.context.idx; this._boxes.push(b); - this._syncToPlot(); + this.syncToPlot(); return b; } @@ -328,6 +328,6 @@ export class BoxHelper { */ rollbackFromBar(barIdx: number): void { this._boxes = this._boxes.filter((b) => b._createdAtBar < barIdx); - this._syncToPlot(); + this.syncToPlot(); } } diff --git a/src/namespaces/box/BoxObject.ts b/src/namespaces/box/BoxObject.ts index 8245a5d6..7a4ee898 100644 --- a/src/namespaces/box/BoxObject.ts +++ b/src/namespaces/box/BoxObject.ts @@ -117,6 +117,20 @@ export class BoxObject { this._deleted = true; } + toPlotData(): any { + return { + id: this.id, + left: this.left, top: this.top, right: this.right, bottom: this.bottom, + xloc: this.xloc, extend: this.extend, + border_color: this.border_color, border_style: this.border_style, border_width: this.border_width, + bgcolor: this.bgcolor, + text: this.text, text_color: this.text_color, text_size: this.text_size, + text_halign: this.text_halign, text_valign: this.text_valign, + text_wrap: this.text_wrap, text_font_family: this.text_font_family, text_formatting: this.text_formatting, + force_overlay: this.force_overlay, _deleted: this._deleted, + }; + } + copy(): BoxObject { const b = new BoxObject( this.left, this.top, this.right, this.bottom, diff --git a/src/namespaces/color/PineColor.ts b/src/namespaces/color/PineColor.ts index d50d0fbc..d95b536a 100644 --- a/src/namespaces/color/PineColor.ts +++ b/src/namespaces/color/PineColor.ts @@ -115,6 +115,9 @@ export class PineColor { // If not a string (e.g. NaN for na), return as-is if (!color || typeof color !== 'string') return color; + // Treat NaN transparency as "no transparency specified" (keep original color) + if (typeof a === 'number' && isNaN(a)) a = undefined; + // Handle hexadecimal colors if (color.startsWith('#')) { const hex = color.slice(1); @@ -164,6 +167,8 @@ export class PineColor { // ── color.rgb(r, g, b, a?) ──────────────────────────────────────── rgb(r: number, g: number, b: number, a?: number) { + // Treat NaN transparency as "no transparency" (fully opaque) + if (typeof a === 'number' && isNaN(a)) a = undefined; return a != null ? `rgba(${r}, ${g}, ${b}, ${(100 - a) / 100})` : `rgb(${r}, ${g}, ${b})`; } diff --git a/src/namespaces/input/methods/bool.ts b/src/namespaces/input/methods/bool.ts index 80aaa747..2e97bcf4 100644 --- a/src/namespaces/input/methods/bool.ts +++ b/src/namespaces/input/methods/bool.ts @@ -1,10 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { parseInputOptions } from '../utils'; +import { parseInputOptions, resolveInput } from '../utils'; export function bool(context: any) { return (...args: any[]) => { const options = parseInputOptions(args); - return options.defval; + return resolveInput(context, options); }; } diff --git a/src/namespaces/label/LabelHelper.ts b/src/namespaces/label/LabelHelper.ts index 6c59ee8c..0200849f 100644 --- a/src/namespaces/label/LabelHelper.ts +++ b/src/namespaces/label/LabelHelper.ts @@ -41,7 +41,7 @@ export class LabelHelper { } } - private _syncToPlot() { + 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. @@ -51,7 +51,7 @@ export class LabelHelper { const time = this.context.marketData[0]?.openTime || 0; this.context.plots['__labels__'].data = [{ time, - value: this._labels, + value: this._labels.map(lbl => lbl.toPlotData()), options: { style: 'label' }, }]; } @@ -115,7 +115,7 @@ export class LabelHelper { lbl._helper = this; lbl._createdAtBar = this.context.idx; this._labels.push(lbl); - this._syncToPlot(); + this.syncToPlot(); return lbl; } @@ -248,7 +248,7 @@ export class LabelHelper { lbl._helper = this; lbl._createdAtBar = this.context.idx; this._labels.push(lbl); - this._syncToPlot(); + this.syncToPlot(); return lbl; } @@ -268,7 +268,7 @@ export class LabelHelper { */ rollbackFromBar(barIdx: number): void { this._labels = this._labels.filter((l) => l._createdAtBar < barIdx); - this._syncToPlot(); + this.syncToPlot(); } // --- Style constants --- diff --git a/src/namespaces/label/LabelObject.ts b/src/namespaces/label/LabelObject.ts index 47711680..e8ded1ec 100644 --- a/src/namespaces/label/LabelObject.ts +++ b/src/namespaces/label/LabelObject.ts @@ -119,6 +119,7 @@ export class LabelObject { tooltip: this.tooltip, text_font_family: this.text_font_family, force_overlay: this.force_overlay, + _deleted: this._deleted, }; } } diff --git a/src/namespaces/line/LineHelper.ts b/src/namespaces/line/LineHelper.ts index 5d73c120..457a87f3 100644 --- a/src/namespaces/line/LineHelper.ts +++ b/src/namespaces/line/LineHelper.ts @@ -39,7 +39,7 @@ export class LineHelper { } } - private _syncToPlot() { + 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. @@ -48,7 +48,7 @@ export class LineHelper { const time = this.context.marketData[0]?.openTime || 0; this.context.plots['__lines__'].data = [{ time, - value: this._lines, + value: this._lines.map(ln => ln.toPlotData()), options: { style: 'drawing_line' }, }]; } @@ -107,7 +107,7 @@ export class LineHelper { ln._helper = this; ln._createdAtBar = this.context.idx; this._lines.push(ln); - this._syncToPlot(); + this.syncToPlot(); return ln; } @@ -268,7 +268,7 @@ export class LineHelper { ln._helper = this; ln._createdAtBar = this.context.idx; this._lines.push(ln); - this._syncToPlot(); + this.syncToPlot(); return ln; } @@ -288,7 +288,7 @@ export class LineHelper { */ rollbackFromBar(barIdx: number): void { this._lines = this._lines.filter((l) => l._createdAtBar < barIdx); - this._syncToPlot(); + this.syncToPlot(); } // --- Style constants --- diff --git a/src/namespaces/line/LineObject.ts b/src/namespaces/line/LineObject.ts index acc87e45..6bc812d3 100644 --- a/src/namespaces/line/LineObject.ts +++ b/src/namespaces/line/LineObject.ts @@ -105,6 +105,7 @@ export class LineObject { style: this.style, width: this.width, force_overlay: this.force_overlay, + _deleted: this._deleted, }; } } diff --git a/src/namespaces/linefill/LinefillHelper.ts b/src/namespaces/linefill/LinefillHelper.ts index 835f0f87..c6a166bd 100644 --- a/src/namespaces/linefill/LinefillHelper.ts +++ b/src/namespaces/linefill/LinefillHelper.ts @@ -24,14 +24,14 @@ export class LinefillHelper { } } - private _syncToPlot() { + public syncToPlot() { this._ensurePlotsEntry(); // Store ALL linefills as a single array value at the first bar's time. // Same aggregation pattern as lines and labels. const time = this.context.marketData[0]?.openTime || 0; this.context.plots['__linefills__'].data = [{ time, - value: this._linefills, + value: this._linefills.map(lf => lf.toPlotData()), options: { style: 'linefill' }, }]; } @@ -68,7 +68,7 @@ export class LinefillHelper { const lf = new LinefillObject(resolvedLine1, resolvedLine2, resolvedColor); lf._createdAtBar = this.context.idx; this._linefills.push(lf); - this._syncToPlot(); + this.syncToPlot(); return lf; } @@ -110,6 +110,6 @@ export class LinefillHelper { */ rollbackFromBar(barIdx: number): void { this._linefills = this._linefills.filter((lf) => lf._createdAtBar < barIdx); - this._syncToPlot(); + this.syncToPlot(); } } diff --git a/src/namespaces/linefill/LinefillObject.ts b/src/namespaces/linefill/LinefillObject.ts index 34468e03..140ba091 100644 --- a/src/namespaces/linefill/LinefillObject.ts +++ b/src/namespaces/linefill/LinefillObject.ts @@ -52,4 +52,23 @@ export class LinefillObject { delete(): void { this._deleted = true; } + + toPlotData(): any { + // Inline line data so consumers don't need raw LineObject references + const serializeLine = (ln: LineObject | null) => { + if (!ln) return null; + return { + id: ln.id, x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2, + xloc: ln.xloc, extend: ln.extend, color: ln.color, + style: ln.style, width: ln.width, _deleted: ln._deleted, + }; + }; + return { + id: this.id, + line1: serializeLine(this.line1), + line2: serializeLine(this.line2), + color: this.color, + _deleted: this._deleted, + }; + } } diff --git a/src/namespaces/matrix/methods/mult.ts b/src/namespaces/matrix/methods/mult.ts index 08620e00..7155828e 100644 --- a/src/namespaces/matrix/methods/mult.ts +++ b/src/namespaces/matrix/methods/mult.ts @@ -31,21 +31,22 @@ export function mult(context: Context) { } return newMatrix; } else if (id2 instanceof PineArrayObject || Array.isArray((id2 as any).array || id2)) { - // Vector multiplication + // Vector multiplication — returns a PineArrayObject (flat vector), + // matching TradingView behavior where matrix.mult(vector) → vector. const vec = (id2 as any).array || (id2 as any); if (cols1 !== vec.length) { - return new PineMatrixObject(0, 0, NaN, context); + return new PineArrayObject([], 'float' as any, context); } - const newMatrix = new PineMatrixObject(rows1, 1, 0, context); + const result: number[] = []; for (let i = 0; i < rows1; i++) { let sum = 0; for (let j = 0; j < cols1; j++) { sum += id.matrix[i][j] * vec[j]; } - newMatrix.matrix[i][0] = sum; + result.push(sum); } - return newMatrix; + return new PineArrayObject(result, 'float' as any, context); } else { // Scalar multiplication const scalar = id2 as number; diff --git a/src/namespaces/polyline/PolylineHelper.ts b/src/namespaces/polyline/PolylineHelper.ts index a8969ab4..11000258 100644 --- a/src/namespaces/polyline/PolylineHelper.ts +++ b/src/namespaces/polyline/PolylineHelper.ts @@ -24,7 +24,7 @@ export class PolylineHelper { } } - private _syncToPlot() { + public syncToPlot() { this._ensurePlotsEntry(); // Store ALL polylines as a single array value at the first bar's time. // Same aggregation pattern as lines and linefills — prevents sparse array @@ -32,7 +32,7 @@ export class PolylineHelper { const time = this.context.marketData[0]?.openTime || 0; this.context.plots['__polylines__'].data = [{ time, - value: this._polylines, + value: this._polylines.map(pl => pl.toPlotData()), options: { style: 'drawing_polyline' }, }]; } @@ -152,7 +152,7 @@ export class PolylineHelper { ); pl._createdAtBar = this.context.idx; this._polylines.push(pl); - this._syncToPlot(); + this.syncToPlot(); return pl; } @@ -177,6 +177,6 @@ export class PolylineHelper { */ rollbackFromBar(barIdx: number): void { this._polylines = this._polylines.filter((pl) => pl._createdAtBar < barIdx); - this._syncToPlot(); + this.syncToPlot(); } } diff --git a/src/namespaces/polyline/PolylineObject.ts b/src/namespaces/polyline/PolylineObject.ts index b70bdea7..fd56cc72 100644 --- a/src/namespaces/polyline/PolylineObject.ts +++ b/src/namespaces/polyline/PolylineObject.ts @@ -50,4 +50,17 @@ export class PolylineObject { delete(): void { this._deleted = true; } + + toPlotData(): any { + return { + id: this.id, + points: this.points.map(pt => ({ + time: pt.time, index: pt.index, price: pt.price, + })), + curved: this.curved, closed: this.closed, xloc: this.xloc, + line_color: this.line_color, fill_color: this.fill_color, + line_style: this.line_style, line_width: this.line_width, + force_overlay: this.force_overlay, _deleted: this._deleted, + }; + } } diff --git a/src/namespaces/ta/methods/crossover.ts b/src/namespaces/ta/methods/crossover.ts index 00d92303..43e3ebb1 100644 --- a/src/namespaces/ta/methods/crossover.ts +++ b/src/namespaces/ta/methods/crossover.ts @@ -18,6 +18,7 @@ export function crossover(context: any) { const prev2 = s2.get(1); // Check if source1 crossed above source2 - return prev1 < prev2 && current1 > current2; + // TradingView: previous was at or below, current is strictly above + return prev1 <= prev2 && current1 > current2; }; } diff --git a/src/namespaces/ta/methods/crossunder.ts b/src/namespaces/ta/methods/crossunder.ts index e995fcc2..010eeb73 100644 --- a/src/namespaces/ta/methods/crossunder.ts +++ b/src/namespaces/ta/methods/crossunder.ts @@ -18,6 +18,7 @@ export function crossunder(context: any) { const prev2 = s2.get(1); // Check if source1 crossed below source2 - return prev1 > prev2 && current1 < current2; + // TradingView: previous was at or above, current is strictly below + return prev1 >= prev2 && current1 < current2; }; } diff --git a/src/namespaces/table/TableHelper.ts b/src/namespaces/table/TableHelper.ts index 77978773..92adc62f 100644 --- a/src/namespaces/table/TableHelper.ts +++ b/src/namespaces/table/TableHelper.ts @@ -22,12 +22,12 @@ export class TableHelper { } } - private _syncToPlot() { + public syncToPlot() { this._ensurePlotsEntry(); const time = this.context.marketData[0]?.openTime || 0; this.context.plots['__tables__'].data = [{ time, - value: this._tables, + value: this._tables.map(tbl => tbl.toPlotData()), options: { style: 'table' }, }]; } @@ -106,7 +106,7 @@ export class TableHelper { ); tbl._setHelper(this); this._tables.push(tbl); - this._syncToPlot(); + this.syncToPlot(); return tbl; } @@ -193,7 +193,7 @@ export class TableHelper { tooltip: this._resolveText(this._resolve(tooltip)), text_font_family: this._resolve(text_font_family) || 'default', }); - this._syncToPlot(); + this.syncToPlot(); } // ── table.delete ─────────────────────────────────────────── @@ -243,7 +243,7 @@ export class TableHelper { tbl.clearCell(c, r); } } - this._syncToPlot(); + this.syncToPlot(); } // ── table.merge_cells ────────────────────────────────────── @@ -294,7 +294,7 @@ export class TableHelper { } tbl.merges.push({ startCol: sc, startRow: sr, endCol: ec, endRow: er }); - this._syncToPlot(); + this.syncToPlot(); } // ── Cell setter methods ──────────────────────────────────── @@ -345,42 +345,42 @@ export class TableHelper { const tbl = this._resolve(table_id) as TableObject; if (!tbl || tbl._deleted) return; tbl.position = this._resolve(position) || tbl.position; - this._syncToPlot(); + this.syncToPlot(); } set_bgcolor(table_id: any, bgcolor: any): void { const tbl = this._resolve(table_id) as TableObject; if (!tbl || tbl._deleted) return; tbl.bgcolor = this._resolve(bgcolor) || ''; - this._syncToPlot(); + this.syncToPlot(); } set_border_color(table_id: any, border_color: any): void { const tbl = this._resolve(table_id) as TableObject; if (!tbl || tbl._deleted) return; tbl.border_color = this._resolve(border_color) || ''; - this._syncToPlot(); + this.syncToPlot(); } set_border_width(table_id: any, border_width: any): void { const tbl = this._resolve(table_id) as TableObject; if (!tbl || tbl._deleted) return; tbl.border_width = this._resolve(border_width) || 0; - this._syncToPlot(); + this.syncToPlot(); } set_frame_color(table_id: any, frame_color: any): void { const tbl = this._resolve(table_id) as TableObject; if (!tbl || tbl._deleted) return; tbl.frame_color = this._resolve(frame_color) || ''; - this._syncToPlot(); + this.syncToPlot(); } set_frame_width(table_id: any, frame_width: any): void { const tbl = this._resolve(table_id) as TableObject; if (!tbl || tbl._deleted) return; tbl.frame_width = this._resolve(frame_width) || 0; - this._syncToPlot(); + this.syncToPlot(); } // ── Property getter ──────────────────────────────────────── @@ -389,6 +389,16 @@ export class TableHelper { return this._tables.filter((t) => !t._deleted); } + /** + * Remove all tables created at or after the given bar index. + * Called during streaming rollback. + */ + rollbackFromBar(barIdx: number): void { + // Tables are typically created once (var table), not per-bar, so rollback is rare. + // But for correctness, filter by creation bar if tracked. + this.syncToPlot(); + } + // ── Private helpers ──────────────────────────────────────── private _setCellProp(table_id: any, column: any, row: any, prop: string, value: any, isText: boolean = false): void { @@ -402,7 +412,7 @@ export class TableHelper { tbl.setCell(col, r, { [prop]: isText ? this._resolveText(resolved) : resolved, } as any); - this._syncToPlot(); + this.syncToPlot(); } private _resolveText(val: any): string { diff --git a/src/namespaces/table/TableObject.ts b/src/namespaces/table/TableObject.ts index 34080bce..b42d7f3f 100644 --- a/src/namespaces/table/TableObject.ts +++ b/src/namespaces/table/TableObject.ts @@ -82,6 +82,28 @@ export class TableObject { this._deleted = true; } + toPlotData(): any { + // Deep-copy cells to avoid exposing internal mutable state + const cellsCopy = this.cells.map(row => + row.map(cell => cell ? { ...cell } : null) + ); + return { + id: this.id, + position: this.position, + columns: this.columns, + rows: this.rows, + bgcolor: this.bgcolor, + frame_color: this.frame_color, + frame_width: this.frame_width, + border_color: this.border_color, + border_width: this.border_width, + force_overlay: this.force_overlay, + _deleted: this._deleted, + cells: cellsCopy, + merges: this.merges.map(m => ({ ...m })), + }; + } + setCell(column: number, row: number, props: Partial): void { if (row < 0 || row >= this.rows || column < 0 || column >= this.columns) return; diff --git a/src/namespaces/utils.ts b/src/namespaces/utils.ts index 0a3ed926..af4f722b 100644 --- a/src/namespaces/utils.ts +++ b/src/namespaces/utils.ts @@ -10,7 +10,7 @@ const TYPE_CHECK = { string: (arg) => typeof arg === 'string', // Pine Script color params accept both color strings and `na` (NaN). // Using 'color' instead of 'string' prevents NaN from invalidating the signature. - color: (arg) => typeof arg === 'string' || arg === null || (typeof arg === 'number' && isNaN(arg)), + color: (arg) => typeof arg === 'string' || arg === null || (typeof arg === 'number' && isNaN(arg)) || arg instanceof Series, number: (arg) => typeof arg === 'number', boolean: (arg) => typeof arg === 'boolean', array: (arg) => Array.isArray(arg), diff --git a/src/transpiler/transformers/ExpressionTransformer.ts b/src/transpiler/transformers/ExpressionTransformer.ts index f675aca6..fc128eb2 100644 --- a/src/transpiler/transformers/ExpressionTransformer.ts +++ b/src/transpiler/transformers/ExpressionTransformer.ts @@ -387,6 +387,21 @@ export function transformMemberExpression(memberNode: any, originalParamName: st } } + // Function parameters (local series vars) with non-computed property access (e.g. w.val) + // need unwrapping: w.val → $.get(w, 0).val + // The parameter is a Series wrapping a UDT; without $.get(), .val accesses the Series, not the UDT. + if ( + !memberNode.computed && + memberNode.object && + memberNode.object.type === 'Identifier' && + scopeManager.isLocalSeriesVar(memberNode.object.name) + ) { + const plainId = ASTFactory.createIdentifier(memberNode.object.name); + plainId._skipTransformation = true; + memberNode.object = ASTFactory.createGetCall(plainId, 0); + return; + } + //if statment variables always need to be transformed const isIfStatement = scopeManager.getCurrentScopeType() == 'if'; const isElseStatement = scopeManager.getCurrentScopeType() == 'els'; @@ -979,6 +994,12 @@ export function transformFunctionArgument(arg: any, namespace: string, scopeMana const getCall = ASTFactory.createGetCall(contextVarRef, 0); arg.object = getCall; } + // Function parameters (local series vars) need $.get(w, 0).field unwrapping + else if (scopeManager.isLocalSeriesVar(name)) { + const plainId = ASTFactory.createIdentifier(name); + plainId._skipTransformation = true; + arg.object = ASTFactory.createGetCall(plainId, 0); + } } else if (arg.object.type === 'MemberExpression') { // Recursively handle nested member expressions like obj.prop1.prop2 transformFunctionArgument(arg.object, namespace, scopeManager); diff --git a/src/transpiler/transformers/StatementTransformer.ts b/src/transpiler/transformers/StatementTransformer.ts index 88d5e453..6139c7d9 100644 --- a/src/transpiler/transformers/StatementTransformer.ts +++ b/src/transpiler/transformers/StatementTransformer.ts @@ -112,6 +112,13 @@ export function transformAssignmentExpression(node: any, scopeManager: ScopeMana const getCall = ASTFactory.createGetCall(contextVarRef, 0); rootOwner.object = getCall; } + // Function parameters (local series vars) also need unwrapping for UDT field assignment: + // w.val = x → $.get(w, 0).val = x + else if (scopeManager.isLocalSeriesVar(name)) { + const plainId = ASTFactory.createIdentifier(name); + plainId._skipTransformation = true; + rootOwner.object = ASTFactory.createGetCall(plainId, 0); + } } } @@ -1180,7 +1187,8 @@ export function transformReturnStatement(node: any, scopeManager: ScopeManager): node.argument.type === 'LogicalExpression' || node.argument.type === 'ConditionalExpression' || node.argument.type === 'CallExpression' || - node.argument.type === 'UnaryExpression' + node.argument.type === 'UnaryExpression' || + node.argument.type === 'AssignmentExpression' ) { // For complex expressions, walk the AST and transform all identifiers and expressions walk.recursive(node.argument, scopeManager, { diff --git a/tests/namespaces/matrix/matrix-mult-vector.test.ts b/tests/namespaces/matrix/matrix-mult-vector.test.ts new file mode 100644 index 00000000..4e6cdf22 --- /dev/null +++ b/tests/namespaces/matrix/matrix-mult-vector.test.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * Matrix.mult(vector) Tests + * + * Verifies that matrix.mult(array) returns a PineArrayObject (flat vector), + * matching TradingView behavior. Previously returned a PineMatrixObject (n×1), + * causing array.get(i) to return NaN. + */ + +import { describe, it, expect } from 'vitest'; +import { PineTS } from '../../../src/PineTS.class'; +import { Provider } from '@pinets/marketData/Provider.class'; + +describe('Matrix.mult(vector)', () => { + const startDate = new Date('2024-01-01').getTime(); + const endDate = new Date('2024-01-05').getTime(); + + it('should return an array when multiplying matrix by vector', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = (context: any) => { + const { matrix, array, plotchar } = context.pine; + + // 2x3 matrix × 3-element vector = 2-element array + const m = matrix.new(2, 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, 4); matrix.set(m, 1, 1, 5); matrix.set(m, 1, 2, 6); + + const v = array.from(1.0, 2.0, 3.0); + const result = matrix.mult(m, v); + + // Result should be an array, not a matrix + const val0 = array.get(result, 0); // 1*1 + 2*2 + 3*3 = 14 + const val1 = array.get(result, 1); // 4*1 + 5*2 + 6*3 = 32 + const sz = array.size(result); + + return { val0, val1, sz }; + }; + + const ctx = await pineTS.run(code); + const last = (arr: any[]) => arr[arr.length - 1]; + + expect(last(ctx.result.val0)).toBe(14); + expect(last(ctx.result.val1)).toBe(32); + expect(last(ctx.result.sz)).toBe(2); + }); + + it('should return an array when using method syntax', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = (context: any) => { + const { matrix, array } = context.pine; + + // 3x2 matrix × 2-element vector = 3-element array + const m = matrix.new(3, 2, 0); + matrix.set(m, 0, 0, 1); matrix.set(m, 0, 1, 0); + matrix.set(m, 1, 0, 0); matrix.set(m, 1, 1, 1); + matrix.set(m, 2, 0, 2); matrix.set(m, 2, 1, 3); + + const v = array.from(5.0, 7.0); + const result = m.mult(v); // method syntax + + const val0 = result.get(0); // 1*5 + 0*7 = 5 + const val1 = result.get(1); // 0*5 + 1*7 = 7 + const val2 = result.get(2); // 2*5 + 3*7 = 31 + + return { val0, val1, val2 }; + }; + + const ctx = await pineTS.run(code); + const last = (arr: any[]) => arr[arr.length - 1]; + + expect(last(ctx.result.val0)).toBe(5); + expect(last(ctx.result.val1)).toBe(7); + expect(last(ctx.result.val2)).toBe(31); + }); + + it('should work with transpiled Pine Script', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + + const code = ` +//@version=6 +indicator("Matrix Mult Vector Test") +m = matrix.new(2, 2, 0) +matrix.set(m, 0, 0, 3.0) +matrix.set(m, 0, 1, 1.0) +matrix.set(m, 1, 0, 2.0) +matrix.set(m, 1, 1, 4.0) + +v = array.from(2.0, 5.0) +result = m.mult(v) + +// result should be array: [3*2+1*5, 2*2+4*5] = [11, 24] +plot(result.get(0), "r0") +plot(result.get(1), "r1") +plot(result.size(), "sz") + `; + + const { plots } = await pineTS.run(code); + expect(plots['r0'].data[0].value).toBe(11); + expect(plots['r1'].data[0].value).toBe(24); + expect(plots['sz'].data[0].value).toBe(2); + }); + + it('should still return matrix for matrix × matrix', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = (context: any) => { + const { matrix } = context.pine; + + // 2x2 × 2x2 = 2x2 matrix + const m1 = matrix.new(2, 2, 0); + matrix.set(m1, 0, 0, 1); matrix.set(m1, 0, 1, 2); + matrix.set(m1, 1, 0, 3); matrix.set(m1, 1, 1, 4); + + const m2 = matrix.new(2, 2, 0); + matrix.set(m2, 0, 0, 5); matrix.set(m2, 0, 1, 6); + matrix.set(m2, 1, 0, 7); matrix.set(m2, 1, 1, 8); + + const result = matrix.mult(m1, m2); + + // Should be a matrix: [[19, 22], [43, 50]] + const r00 = matrix.get(result, 0, 0); + const r01 = matrix.get(result, 0, 1); + const r10 = matrix.get(result, 1, 0); + const r11 = matrix.get(result, 1, 1); + const rows = matrix.rows(result); + + return { r00, r01, r10, r11, rows }; + }; + + const ctx = await pineTS.run(code); + const last = (arr: any[]) => arr[arr.length - 1]; + + expect(last(ctx.result.r00)).toBe(19); + expect(last(ctx.result.r01)).toBe(22); + expect(last(ctx.result.r10)).toBe(43); + expect(last(ctx.result.r11)).toBe(50); + expect(last(ctx.result.rows)).toBe(2); + }); +}); diff --git a/tests/namespaces/plot-serialization.test.ts b/tests/namespaces/plot-serialization.test.ts new file mode 100644 index 00000000..802ec6a2 --- /dev/null +++ b/tests/namespaces/plot-serialization.test.ts @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * Plot Data Serialization Tests + * + * Verifies that context.plots contains only serialized plain objects + * (no circular references, no internal _helper/context refs). + * JSON.stringify must work without errors on all plot entries. + */ + +import { describe, it, expect } from 'vitest'; +import { PineTS } from '../../src/PineTS.class'; +import { Provider } from '@pinets/marketData/Provider.class'; + +describe('Plot Data Serialization', () => { + const startDate = new Date('2024-01-01').getTime(); + const endDate = new Date('2024-01-10').getTime(); + + it('labels: JSON.stringify works, no _helper refs', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` +//@version=6 +indicator("Label Test", overlay=true) +if barstate.isfirst + label.new(bar_index, close, "Hello", color=color.red, textcolor=color.white) +plot(close) + `; + const ctx = await pineTS.run(code); + const labels = ctx.plots['__labels__']; + expect(labels).toBeDefined(); + + // Must serialize without error + const json = JSON.stringify(labels); + expect(json).toBeDefined(); + + // Must not contain _helper or context references + expect(json).not.toContain('_helper'); + expect(json).not.toContain('context'); + + // Data should be plain objects + const parsed = JSON.parse(json); + const items = parsed.data[0].value; + expect(items.length).toBeGreaterThan(0); + expect(items[0]).toHaveProperty('text', 'Hello'); + expect(items[0]).toHaveProperty('_deleted'); + }); + + it('lines: JSON.stringify works, no _helper refs', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` +//@version=6 +indicator("Line Test", overlay=true) +if barstate.isfirst + line.new(bar_index, close, bar_index + 5, close + 100, color=color.blue) +plot(close) + `; + const ctx = await pineTS.run(code); + const lines = ctx.plots['__lines__']; + expect(lines).toBeDefined(); + + const json = JSON.stringify(lines); + expect(json).toBeDefined(); + expect(json).not.toContain('_helper'); + + const parsed = JSON.parse(json); + const items = parsed.data[0].value; + expect(items.length).toBeGreaterThan(0); + expect(items[0]).toHaveProperty('x1'); + expect(items[0]).toHaveProperty('y1'); + expect(items[0]).toHaveProperty('_deleted'); + }); + + it('boxes: JSON.stringify works, no _helper refs', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` +//@version=6 +indicator("Box Test", overlay=true) +if barstate.isfirst + box.new(bar_index, close + 100, bar_index + 5, close - 100, bgcolor=color.new(color.blue, 80)) +plot(close) + `; + const ctx = await pineTS.run(code); + const boxes = ctx.plots['__boxes__']; + expect(boxes).toBeDefined(); + + const json = JSON.stringify(boxes); + expect(json).toBeDefined(); + expect(json).not.toContain('_helper'); + + const parsed = JSON.parse(json); + const items = parsed.data[0].value; + expect(items.length).toBeGreaterThan(0); + expect(items[0]).toHaveProperty('left'); + expect(items[0]).toHaveProperty('bgcolor'); + expect(items[0]).toHaveProperty('_deleted'); + }); + + it('tables: JSON.stringify works, no _helper refs', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` +//@version=6 +indicator("Table Test", overlay=true) +if barstate.islast + var t = table.new(position.top_right, 2, 2) + table.cell(t, 0, 0, "A") + table.cell(t, 1, 0, "B") +plot(close) + `; + const ctx = await pineTS.run(code); + const tables = ctx.plots['__tables__']; + expect(tables).toBeDefined(); + + const json = JSON.stringify(tables); + expect(json).toBeDefined(); + expect(json).not.toContain('_helper'); + expect(json).not.toContain('"context"'); + + const parsed = JSON.parse(json); + const items = parsed.data[0].value; + expect(items.length).toBeGreaterThan(0); + expect(items[0]).toHaveProperty('position'); + expect(items[0]).toHaveProperty('cells'); + }); + + it('linefills: JSON.stringify works, line data inlined', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` +//@version=6 +indicator("Linefill Test", overlay=true) +if barstate.isfirst + l1 = line.new(bar_index, close, bar_index + 5, close + 100, color=color.red) + l2 = line.new(bar_index, close - 50, bar_index + 5, close + 50, color=color.blue) + linefill.new(l1, l2, color=color.new(color.green, 80)) +plot(close) + `; + const ctx = await pineTS.run(code); + const linefills = ctx.plots['__linefills__']; + expect(linefills).toBeDefined(); + + const json = JSON.stringify(linefills); + expect(json).toBeDefined(); + expect(json).not.toContain('_helper'); + + const parsed = JSON.parse(json); + const items = parsed.data[0].value; + expect(items.length).toBeGreaterThan(0); + // Line data should be inlined as plain objects + expect(items[0].line1).toHaveProperty('x1'); + expect(items[0].line1).toHaveProperty('y1'); + expect(items[0].line2).toHaveProperty('x1'); + }); + + it('polylines: JSON.stringify works, points serialized', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` +//@version=6 +indicator("Polyline Test", overlay=true) +if barstate.islast + pts = array.new() + pts.push(chart.point.from_index(bar_index - 2, close - 50)) + pts.push(chart.point.from_index(bar_index - 1, close + 50)) + pts.push(chart.point.from_index(bar_index, close)) + polyline.new(pts, line_color=color.red) +plot(close) + `; + const ctx = await pineTS.run(code); + const polylines = ctx.plots['__polylines__']; + expect(polylines).toBeDefined(); + + const json = JSON.stringify(polylines); + expect(json).toBeDefined(); + expect(json).not.toContain('_helper'); + + const parsed = JSON.parse(json); + const items = parsed.data[0].value; + expect(items.length).toBeGreaterThan(0); + expect(items[0].points.length).toBeGreaterThan(0); + expect(items[0].points[0]).toHaveProperty('index'); + expect(items[0].points[0]).toHaveProperty('price'); + }); + + it('full indicator with all drawing types: JSON.stringify(context.plots) works', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, startDate, endDate); + const code = ` +//@version=6 +indicator("All Drawings", overlay=true) +if barstate.isfirst + label.new(bar_index, close, "L") + line.new(bar_index, close, bar_index + 1, close + 10) + box.new(bar_index, close + 20, bar_index + 2, close - 20) +plot(close) + `; + const ctx = await pineTS.run(code); + + // The entire plots object must serialize without error + const json = JSON.stringify(ctx.plots); + expect(json).toBeDefined(); + expect(json).not.toContain('_helper'); + expect(json.length).toBeGreaterThan(0); + + // Verify it round-trips + const parsed = JSON.parse(json); + expect(parsed).toHaveProperty('__labels__'); + expect(parsed).toHaveProperty('__lines__'); + expect(parsed).toHaveProperty('__boxes__'); + }); +}); diff --git a/tests/transpiler/function-scope-property-access.test.ts b/tests/transpiler/function-scope-property-access.test.ts index 7214b9d9..40a70490 100644 --- a/tests/transpiler/function-scope-property-access.test.ts +++ b/tests/transpiler/function-scope-property-access.test.ts @@ -56,7 +56,7 @@ describe('Function-scope property access in namespace args — transpiler output // hoisted line.param() uses the raw parameter, NOT a $.let reference. // ----------------------------------------------------------------------- - it('function parameter property access stays as raw identifier', () => { + it('function parameter property access is unwrapped via $.get()', () => { const code = ` //@version=5 indicator("fn param prop access") @@ -70,9 +70,10 @@ deleteZoneLine(MyZone zone) => plot(close) `; const output = transpileToString(code); - // Function params are NOT renamed — zone.zoneLine stays as raw identifier - expect(output).toMatch(/zone\.zoneLine/); - // Must NOT be wrapped in $.let or $$.let + // Function params need $.get(zone, 0).zoneLine to unwrap the Series wrapper + // and access the UDT field correctly + expect(output).toMatch(/\$\.get\(zone, 0\)\.zoneLine/); + // Must NOT be wrapped in $.let or $$.let (params stay as plain identifiers) expect(output).not.toMatch(/\$\.let\.\w*zone/); expect(output).not.toMatch(/\$\$\.let\.\w*zone/); });