/* eslint-disable @typescript-eslint/no-explicit-any */ /** * PineTS adapter — the ONLY module in the codebase allowed to import `pinets`. * * PineTS (https://github.com/alaa-eddine/PineTS, AGPL-3.0/commercial, pinned * in package.json) transpiles Pine v5/v6 source and runs it over raw kline * arrays. This adapter normalizes it to the IScriptEngine contract: * * - compile(): static guard against unsupported constructs (friendly Persian * error naming the feature), transpile+scan via Indicator.prepare(), then a * short "probe run" over synthetic bars to discover plot calls (plot titles * and styles are runtime information in Pine, not static metadata). * - run(): full-history execution; plot series are re-aligned to the caller's * bar array by bar time (NaN where the script produced nothing). * * PineTS has no per-bar incremental API (its update() is viewport-only), so * capabilities.incremental = false — the SandboxRunner falls back to throttled * full recomputes. */ import { PineTS, Indicator } from "pinets"; import { Bar, CompiledScript, CompileMeta, IScriptEngine, RunResult, ScriptCompileError, ScriptInputMeta, ScriptInputType, ScriptPlotMeta, } from "./types"; const PINETS_VERSION = "0.9.27"; // keep in sync with package.json pin /** Internal plot buckets PineTS always attaches — not user plots. */ const INTERNAL_PLOT_KEYS = /^__.+__$/; /** Max source size accepted by the engine (server enforces the same limit). */ const MAX_SOURCE_BYTES = 64 * 1024; /** * Constructs outside the supported subset. Detected on comment/string-stripped * source so users get a clear, early, named error instead of a silent drop or * a cryptic transpiler failure. */ const UNSUPPORTED: { re: RegExp; name: string }[] = [ { re: /\bstrategy\s*[.(]/, name: "strategy.*" }, { re: /\bbox\s*\.\s*new\b|\bbox\s*\.\s*(delete|set_)/, name: "box.*" }, { re: /\blabel\s*\.\s*new\b|\blabel\s*\.\s*(delete|set_)/, name: "label.*" }, { re: /\btable\s*\.\s*new\b|\btable\s*\.\s*(cell|delete)/, name: "table.*" }, { re: /\bmatrix\s*\./, name: "matrix.*" }, { re: /\bpolyline\s*\./, name: "polyline.*" }, { re: /\blinefill\s*\./, name: "linefill.*" }, { re: /\brequest\s*\./, name: "request.*" }, { re: /\bline\s*\.\s*new\b/, name: "line.new" }, ]; /** Strip // comments and string literals so guards don't fire on text. */ function stripCommentsAndStrings(source: string): string { return source .replace(/"(?:[^"\\\n]|\\.)*"/g, '""') .replace(/'(?:[^'\\\n]|\\.)*'/g, "''") .replace(/\/\/[^\n]*/g, ""); } /** 1-based line of the first regex match in stripped source. */ function lineOf(stripped: string, re: RegExp): number | undefined { const m = re.exec(stripped); if (!m) return undefined; return stripped.slice(0, m.index).split("\n").length; } /** Deterministic synthetic bars for the compile-time probe run. */ function probeBars(n = 60): any[] { const out: any[] = []; let price = 100; for (let i = 0; i < n; i++) { price += Math.sin(i / 4) * 1.5 + (i % 7 === 0 ? -1 : 0.3); const t = 1_700_000_000_000 + i * 3_600_000; out.push({ openTime: t, closeTime: t + 3_599_999, open: price, high: price + 1.2, low: price - 1.1, close: price + 0.4, volume: 1_000 + i * 3, }); } return out; } function toKlines(bars: Bar[]): any[] { // Estimate bar span for closeTime (PineTS normalizes anyway). const span = bars.length > 1 ? Math.max(1, bars[1].time - bars[0].time) : 60_000; return bars.map((b) => ({ openTime: b.time, closeTime: b.time + span - 1, open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume, })); } const INPUT_TYPES: Record = { int: "int", float: "float", bool: "bool", string: "string", source: "source", timeframe: "timeframe", }; /** alertcondition(cond, title=..., message=...) — static scan (deterministic). */ function scanAlerts(stripped: string, source: string): { id: string; title: string }[] { const out: { id: string; title: string }[] = []; const re = /\balertcondition\s*\(/g; let m: RegExpExecArray | null; let i = 0; while ((m = re.exec(stripped))) { // Read the original source from the same offset to recover the real title string. const tail = source.slice(m.index, m.index + 400); const t = /title\s*=\s*"([^"]*)"/.exec(tail) ?? /\(\s*[^,]+,\s*"([^"]*)"/.exec(tail); const title = t?.[1] ?? `هشدار ${i + 1}`; out.push({ id: `alert_${i}`, title }); i++; } return out; } type Handle = { source: string; /** * Fresh Indicator per run. Input overrides MUST go through the constructor — * post-prepare writes to `ind.input` are silently ignored by the engine * (verified against pinets 0.9.27; both varId and title keys work here). */ makeIndicator: (inputs: Record) => any; inputIds: string[]; }; export class PinetsEngine implements IScriptEngine { readonly name = "pinets"; readonly version = PINETS_VERSION; readonly capabilities = { incremental: false }; async compile(source: string): Promise { if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { throw new ScriptCompileError("حجم اسکریپت بیش از حد مجاز است (حداکثر ۶۴ کیلوبایت)."); } const stripped = stripCommentsAndStrings(source); for (const u of UNSUPPORTED) { if (u.re.test(stripped)) { throw new ScriptCompileError( `قابلیت «${u.name}» در اسکریپت شخصی پشتیبانی نمی‌شود. زیرمجموعه‌ی پشتیبانی‌شده: محاسبات سری، ta.*، math.*، input.*، plot/hline/alertcondition.`, lineOf(stripped, u.re), ); } } // Transpile + scan inputs/props. let ind: any; try { ind = new Indicator(source); ind.prepare(); } catch (e: any) { throw new ScriptCompileError(toFriendlyCompileMessage(e), extractLine(e)); } // Input metadata. PineTS's typed scan lives in a private field whose // presence differs between its build artifacts (node ESM vs CJS), so the // primary source is our own static scan over the supported input.* forms, // with defvals taken from the PUBLIC `ind.input` live map (engine-resolved) // and `_inputMeta` used only as best-effort enrichment when present. const inputs = buildInputMeta(source, ind); const overlay = Boolean(ind.prop?.overlay); const title = String(ind._propValues?.title ?? matchTitle(source) ?? "اسکریپت شخصی"); // Probe run — discover plot calls (titles + static style/color). let plots: ScriptPlotMeta[]; let probeCtx: any; try { const pine = new PineTS(probeBars(), "PROBE", "60"); probeCtx = await pine.run(ind); plots = extractPlotMeta(probeCtx); } catch (e: any) { throw new ScriptCompileError(toFriendlyCompileMessage(e), extractLine(e)); } if (plots.length === 0) { throw new ScriptCompileError("اسکریپت هیچ خروجی‌ای رسم نمی‌کند — دست‌کم یک plot() لازم است."); } const meta: CompileMeta = { title, overlay, inputs, plots, alerts: scanAlerts(stripped, source), }; const handle: Handle = { source, makeIndicator: (inputValues: Record) => { const fresh = new Indicator(source, inputValues); fresh.prepare(); return fresh; }, inputIds: inputs.map((i) => i.id), }; return { meta, handle }; } async run(compiled: CompiledScript, bars: Bar[], inputValues: Record): Promise { const h = compiled.handle as Handle; // Only known input ids, only defined values — unknown keys keep defaults. const overrides: Record = {}; for (const id of h.inputIds) { if (id in inputValues && inputValues[id] !== undefined) overrides[id] = inputValues[id]; } const ind = h.makeIndicator(overrides); const pine = new PineTS(toKlines(bars), "CHART", "0"); let ctx: any; try { ctx = await pine.run(ind); } catch (e: any) { // Runtime failures surface as a compile-style error message (Persian). throw new ScriptCompileError(toFriendlyCompileMessage(e), extractLine(e)); } // Re-align plot series to the caller's bars by openTime. const timeIndex = new Map(); bars.forEach((b, i) => timeIndex.set(b.time, i)); const plots: Record = {}; for (const key of Object.keys(ctx?.plots ?? {})) { if (INTERNAL_PLOT_KEYS.test(key)) continue; const series = new Array(bars.length).fill(NaN); const data: any[] = ctx.plots[key]?.data ?? []; for (const p of data) { const idx = timeIndex.get(p?.time); if (idx !== undefined) { const v = typeof p?.value === "number" && isFinite(p.value) ? p.value : NaN; series[idx] = v; } } plots[key] = series; } // Alerts → 0/1 per-bar series keyed by meta alert id (best effort). const alerts: Record = {}; const alertMeta = compiled.meta.alerts; if (alertMeta.length) { const fired: any[] = Array.isArray(ctx?.alerts) ? ctx.alerts : []; for (const a of alertMeta) { const series = new Array(bars.length).fill(0); for (const f of fired) { if ((f?.title ?? f?.name) !== a.title) continue; const idx = timeIndex.get(f?.time); if (idx !== undefined) series[idx] = 1; } alerts[a.id] = series; } } const warnings: string[] = (ctx?.warnings ?? []) .map((w: any) => String(w?.message ?? w)) .slice(0, 20); return { plots, alerts, warnings }; } } function matchTitle(source: string): string | undefined { const m = /\bindicator\s*\(\s*(?:title\s*=\s*)?"([^"]*)"/.exec(source); return m?.[1]; } /** * Static input.* scan over the supported subset: * varId = input.int(defval, "Title", minval=…, maxval=…, step=…) * varId = input(defval, "Title") → type inferred from defval * Defvals come from the PUBLIC `ind.input` live map (already resolved by the * engine), so expressions like `close` or arithmetic defaults are handled. * When the engine build exposes `_inputMeta`, its richer fields win. */ function buildInputMeta(source: string, ind: any): ScriptInputMeta[] { const liveMap: Record = ind?.input ?? {}; const richMeta: any[] = Array.isArray(ind?._inputMeta) ? ind._inputMeta : []; const richById = new Map(richMeta.filter((m) => m?.varId).map((m) => [String(m.varId), m])); const out: ScriptInputMeta[] = []; const seen = new Set(); const re = /^[ \t]*(\w+)[ \t]*=[ \t]*input(?:\.(int|float|bool|string|source|timeframe))?\s*\(([^\n]*)/gm; let m: RegExpExecArray | null; while ((m = re.exec(source))) { const id = m[1]; if (seen.has(id)) continue; seen.add(id); const args = m[3] ?? ""; const rich = richById.get(id); const defval = id in liveMap ? liveMap[id] : rich?.defval; // type: explicit namespace → it; bare input() → infer from resolved defval let type: ScriptInputType; if (m[2]) type = INPUT_TYPES[m[2]] ?? "string"; else if (typeof defval === "boolean") type = "bool"; else if (typeof defval === "number") type = Number.isInteger(defval) ? "int" : "float"; else type = "string"; const title = rich?.title ?? /(?:title\s*=\s*)?"((?:[^"\\]|\\.)*)"/.exec(args)?.[1] ?? id; const num = (name: string) => { const g = new RegExp(`\\b${name}\\s*=\\s*(-?\\d+(?:\\.\\d+)?)`).exec(args); return g ? Number(g[1]) : undefined; }; out.push({ id, type, title: String(title), defval, min: rich?.minval ?? num("minval"), max: rich?.maxval ?? num("maxval"), step: rich?.step ?? num("step"), options: rich?.options, }); } return out; } function extractLine(e: any): number | undefined { const n = Number(e?.line ?? e?.loc?.line); return Number.isFinite(n) && n > 0 ? n : undefined; } /** Map engine/transpiler errors to a user-facing Persian message. */ function toFriendlyCompileMessage(e: any): string { const raw = String(e?.message ?? e ?? "خطای ناشناخته"); if (/is not defined|is not a function|Unknown (function|namespace)/i.test(raw)) { return `تابع یا شناسه‌ی ناشناخته در اسکریپت: ${raw.slice(0, 160)}`; } if (/Unexpected token|SyntaxError|Parse error/i.test(raw)) { return `خطای نحوی در اسکریپت: ${raw.slice(0, 160)}`; } return `خطا در کامپایل اسکریپت: ${raw.slice(0, 200)}`; } function extractPlotMeta(ctx: any): ScriptPlotMeta[] { const out: ScriptPlotMeta[] = []; for (const key of Object.keys(ctx?.plots ?? {})) { if (INTERNAL_PLOT_KEYS.test(key)) continue; const opts = ctx.plots[key]?.options ?? ctx.plots[key]?.data?.[0]?.options ?? {}; const style = String(opts?.style ?? "").toLowerCase(); const kind: ScriptPlotMeta["kind"] = style.includes("column") || style.includes("histogram") ? "columns" : "line"; out.push({ id: key, title: key, kind, color: typeof opts?.color === "string" ? opts.color : undefined, }); } return out; }