oakscriptjs 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/AGENTS.md +6 -1
  2. package/README.md +1 -1
  3. package/bin/oakscript.mjs +5 -0
  4. package/dist/cjs/check/checks.d.ts +78 -0
  5. package/dist/cjs/check/checks.d.ts.map +1 -0
  6. package/dist/cjs/check/checks.js +109 -0
  7. package/dist/cjs/check/checks.js.map +7 -0
  8. package/dist/cjs/check/format.d.ts +26 -0
  9. package/dist/cjs/check/format.d.ts.map +1 -0
  10. package/dist/cjs/check/format.js +106 -0
  11. package/dist/cjs/check/format.js.map +7 -0
  12. package/dist/cjs/check/harness.d.ts +94 -0
  13. package/dist/cjs/check/harness.d.ts.map +1 -0
  14. package/dist/cjs/check/harness.js +302 -0
  15. package/dist/cjs/check/harness.js.map +7 -0
  16. package/dist/cjs/check/index.d.ts +253 -0
  17. package/dist/cjs/check/index.d.ts.map +1 -0
  18. package/dist/cjs/check/index.js +466 -0
  19. package/dist/cjs/check/index.js.map +7 -0
  20. package/dist/cjs/check/node.d.ts +53 -0
  21. package/dist/cjs/check/node.d.ts.map +1 -0
  22. package/dist/cjs/check/node.js +318 -0
  23. package/dist/cjs/check/node.js.map +7 -0
  24. package/dist/cjs/check/spec.d.ts +44 -0
  25. package/dist/cjs/check/spec.d.ts.map +1 -0
  26. package/dist/cjs/check/spec.js +199 -0
  27. package/dist/cjs/check/spec.js.map +7 -0
  28. package/dist/cjs/index.d.ts +1 -1
  29. package/dist/cjs/index.js +1 -1
  30. package/dist/cjs/index.js.map +1 -1
  31. package/dist/cjs/runtime/series.d.ts +6 -0
  32. package/dist/cjs/runtime/series.d.ts.map +1 -1
  33. package/dist/cjs/runtime/series.js +15 -0
  34. package/dist/cjs/runtime/series.js.map +2 -2
  35. package/dist/cjs/script/constants.d.ts +200 -0
  36. package/dist/cjs/script/constants.d.ts.map +1 -0
  37. package/dist/cjs/script/constants.js +217 -0
  38. package/dist/cjs/script/constants.js.map +7 -0
  39. package/dist/cjs/script/drawing-args.d.ts +25 -0
  40. package/dist/cjs/script/drawing-args.d.ts.map +1 -0
  41. package/dist/cjs/script/drawing-args.js +203 -0
  42. package/dist/cjs/script/drawing-args.js.map +7 -0
  43. package/dist/cjs/script/globals.d.ts +127 -0
  44. package/dist/cjs/script/globals.d.ts.map +1 -0
  45. package/dist/cjs/script/globals.js +17 -0
  46. package/dist/cjs/script/globals.js.map +7 -0
  47. package/dist/cjs/script/index.d.ts +499 -166
  48. package/dist/cjs/script/index.d.ts.map +1 -1
  49. package/dist/cjs/script/index.js +236 -96
  50. package/dist/cjs/script/index.js.map +3 -3
  51. package/dist/esm/check/checks.d.ts +78 -0
  52. package/dist/esm/check/checks.d.ts.map +1 -0
  53. package/dist/esm/check/checks.js +95 -0
  54. package/dist/esm/check/checks.js.map +1 -0
  55. package/dist/esm/check/format.d.ts +26 -0
  56. package/dist/esm/check/format.d.ts.map +1 -0
  57. package/dist/esm/check/format.js +105 -0
  58. package/dist/esm/check/format.js.map +1 -0
  59. package/dist/esm/check/harness.d.ts +94 -0
  60. package/dist/esm/check/harness.d.ts.map +1 -0
  61. package/dist/esm/check/harness.js +317 -0
  62. package/dist/esm/check/harness.js.map +1 -0
  63. package/dist/esm/check/index.d.ts +253 -0
  64. package/dist/esm/check/index.d.ts.map +1 -0
  65. package/dist/esm/check/index.js +470 -0
  66. package/dist/esm/check/index.js.map +1 -0
  67. package/dist/esm/check/node.d.ts +53 -0
  68. package/dist/esm/check/node.d.ts.map +1 -0
  69. package/dist/esm/check/node.js +347 -0
  70. package/dist/esm/check/node.js.map +1 -0
  71. package/dist/esm/check/spec.d.ts +44 -0
  72. package/dist/esm/check/spec.d.ts.map +1 -0
  73. package/dist/esm/check/spec.js +198 -0
  74. package/dist/esm/check/spec.js.map +1 -0
  75. package/dist/esm/index.d.ts +1 -1
  76. package/dist/esm/index.js +1 -1
  77. package/dist/esm/runtime/series.d.ts +6 -0
  78. package/dist/esm/runtime/series.d.ts.map +1 -1
  79. package/dist/esm/runtime/series.js +14 -0
  80. package/dist/esm/runtime/series.js.map +1 -1
  81. package/dist/esm/script/constants.d.ts +200 -0
  82. package/dist/esm/script/constants.d.ts.map +1 -0
  83. package/dist/esm/script/constants.js +151 -0
  84. package/dist/esm/script/constants.js.map +1 -0
  85. package/dist/esm/script/drawing-args.d.ts +25 -0
  86. package/dist/esm/script/drawing-args.d.ts.map +1 -0
  87. package/dist/esm/script/drawing-args.js +171 -0
  88. package/dist/esm/script/drawing-args.js.map +1 -0
  89. package/dist/esm/script/globals.d.ts +127 -0
  90. package/dist/esm/script/globals.d.ts.map +1 -0
  91. package/dist/esm/script/globals.js +28 -0
  92. package/dist/esm/script/globals.js.map +1 -0
  93. package/dist/esm/script/index.d.ts +499 -166
  94. package/dist/esm/script/index.d.ts.map +1 -1
  95. package/dist/esm/script/index.js +256 -83
  96. package/dist/esm/script/index.js.map +1 -1
  97. package/package.json +31 -1
  98. package/skills/oakscript/SKILL.md +62 -26
  99. package/skills/oakscript/examples/ma-cross.js +2 -2
  100. package/skills/oakscript/examples/mtf-trend-filter.js +3 -3
  101. package/skills/oakscript/reference/check.md +150 -0
  102. package/skills/oakscript/reference/errors.md +31 -7
  103. package/skills/oakscript/reference/pine-differences.md +17 -16
  104. package/skills/oakscript/reference/plotting.md +25 -19
  105. package/skills/oakscript/reference/script-structure.md +8 -5
  106. package/skills/oakscript/reference/timeframes-symbols.md +5 -1
  107. package/skills/oakscript/spec.schema.json +199 -0
  108. package/src/check/checks.ts +175 -0
  109. package/src/check/format.ts +117 -0
  110. package/src/check/harness.ts +372 -0
  111. package/src/check/index.ts +708 -0
  112. package/src/check/node.ts +368 -0
  113. package/src/check/spec.ts +200 -0
  114. package/src/index.ts +1 -1
  115. package/src/runtime/series.ts +19 -0
  116. package/src/script/constants.ts +169 -0
  117. package/src/script/drawing-args.ts +179 -0
  118. package/src/script/globals.ts +129 -0
  119. package/src/script/index.ts +457 -81
@@ -0,0 +1,368 @@
1
+ /**
2
+ * @fileoverview The `oakscript check` command (Node.js): checks a script file with {@link checkScript}, adding what
3
+ * needs Node: the error lines of the file (the script is compiled with its file name), a time limit per run, a type
4
+ * check with the TypeScript compiler of the project (when it is installed), bars, specs and references read from files,
5
+ * and a text or JSON output. The exit code is 0 when the check is clean, 2 with warnings only, 1 with an error or a
6
+ * failed spec / reference check.
7
+ *
8
+ * @module check/node
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import * as path from 'node:path';
13
+ import * as vm from 'node:vm';
14
+ import type { Bar } from '../types/index.js';
15
+ import { formatReport } from './format.js';
16
+ import {
17
+ CHECK_BARS,
18
+ checkScript,
19
+ errorLocation,
20
+ validateSpec,
21
+ LIBRARY_PARAMETER,
22
+ SCRIPT_GLOBALS,
23
+ SCRIPT_PREFIX,
24
+ scriptArguments,
25
+ syntheticBars,
26
+ timeframeSeconds,
27
+ wrapSource,
28
+ type CheckContext,
29
+ type CheckError,
30
+ type CheckReference,
31
+ type CheckReport,
32
+ type CheckSpec,
33
+ } from './index.js';
34
+
35
+ const USAGE = `Usage: oakscript check <script.js> [options]
36
+
37
+ Runs an OakScript script like a host (declarations, then a run on bars) and reports its inputs, outputs, values,
38
+ signals, trades, errors with their line, and warnings for the mistakes that give no error.
39
+
40
+ Options:
41
+ --spec <file.json> expected kind, title, overlay, inputs, outputs, plots, signals, trades (see the guide);
42
+ an unknown key is an error
43
+ --checks <file.js> behaviour checks: the body of a function of \`t\` (t.plot, t.signal, t.expect, t.everyBar)
44
+ --scenarios also run on edge-case bars (short history, daily, 1-minute, flat prices, no volume, a gap)
45
+ --reference <file.csv> expected values per bar: a time column and one column per plot title
46
+ --export <file.csv> a chart export: its bars (time, open, high, low, close, volume) are the bars of the run and
47
+ its other columns the expected plot values (columns without a plot are ignored)
48
+ --tolerance <x> relative tolerance of the reference values (default 1e-10)
49
+ --bars <file.csv|json> bars of the run (time, open, high, low, close, volume); default: synthetic bars
50
+ --count <n> number of synthetic bars (default ${CHECK_BARS})
51
+ --timeframe <tf> chart timeframe ("1", "60", "D"...; default: from the bars, else "60")
52
+ --inputs <json> input values by id, e.g. '{"length": 50}'
53
+ --timeout <ms> time limit of each run (default 10000)
54
+ --no-types skip the type check
55
+ --no-sweep skip the runs that look for unused inputs
56
+ --json print the report as JSON
57
+
58
+ Exit code: 0 clean, 2 warnings only, 1 errors or failed checks.`;
59
+
60
+
61
+ export interface CheckFileOptions {
62
+ spec?: CheckSpec;
63
+ reference?: CheckReference;
64
+ /** Behaviour checks (see {@link readChecks} for a file). */
65
+ checks?: (t: CheckContext) => void;
66
+ /** Also run on the edge-case bars. */
67
+ scenarios?: boolean;
68
+ bars?: Bar[];
69
+ count?: number;
70
+ timeframe?: string;
71
+ inputs?: Record<string, unknown>;
72
+ timeout?: number;
73
+ types?: boolean;
74
+ sweep?: boolean;
75
+ /** Folder to resolve `typescript` and `oakscriptjs` from (default: the script's folder). */
76
+ cwd?: string;
77
+ }
78
+
79
+ export interface CheckFileReport extends CheckReport {
80
+ file: string;
81
+ /** Why the type check did not run, when it did not. */
82
+ typesSkipped?: string;
83
+ }
84
+
85
+ /** Compiles a script with its file name: the stack frames and syntax errors give `<file>:line:column`. */
86
+ export function compileFile(filename: string): (source: string) => () => void {
87
+ return (source) => {
88
+ const fn = vm.compileFunction(wrapSource(source), [...SCRIPT_GLOBALS, LIBRARY_PARAMETER], {
89
+ filename,
90
+ }) as (...args: unknown[]) => void;
91
+ const values = scriptArguments();
92
+ return () => fn(...values);
93
+ };
94
+ }
95
+
96
+ /** Runs `fn` with a time limit (the run is stopped, even inside the library). */
97
+ export function withTimeLimit(ms: number): <T>(fn: () => T) => T {
98
+ // the context only calls `fn`: the script and the library run in their own realm, under the context's time limit
99
+ const context = vm.createContext({ run: undefined as unknown });
100
+ const call = new vm.Script('run()');
101
+ return <T>(fn: () => T): T => {
102
+ context.run = fn;
103
+ try {
104
+ return call.runInContext(context, { timeout: ms }) as T;
105
+ } finally {
106
+ context.run = undefined;
107
+ }
108
+ };
109
+ }
110
+
111
+ /** Type-checks a script file with the globals of oakscriptjs/script; `skipped` when TypeScript or the types are missing. */
112
+ export function typecheckFile(file: string, cwd: string): { errors: CheckError[] } | { skipped: string } {
113
+ const req = createRequire(path.join(cwd, 'oakscript-check.js'));
114
+ let ts: typeof import('typescript');
115
+ try {
116
+ ts = req('typescript') as typeof import('typescript');
117
+ } catch {
118
+ return { skipped: 'typescript is not installed in the project (npm i -D typescript)' };
119
+ }
120
+ let globals: string;
121
+ try {
122
+ globals = req.resolve('oakscriptjs/script/globals').replace(/\.js$/, '.d.ts');
123
+ if (!existsSync(globals)) throw new Error(globals);
124
+ } catch {
125
+ return { skipped: 'oakscriptjs/script/globals is not found from the script folder (npm i oakscriptjs)' };
126
+ }
127
+ const program = ts.createProgram([file, globals], {
128
+ allowJs: true,
129
+ checkJs: true,
130
+ noEmit: true,
131
+ strict: true,
132
+ noImplicitAny: false,
133
+ target: ts.ScriptTarget.ES2022,
134
+ module: ts.ModuleKind.ESNext,
135
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
136
+ moduleDetection: ts.ModuleDetectionKind.Force,
137
+ lib: ['lib.es2022.d.ts'],
138
+ types: [],
139
+ skipLibCheck: true,
140
+ });
141
+ const target = path.resolve(file);
142
+ const errors = ts
143
+ .getPreEmitDiagnostics(program)
144
+ .filter((d) => d.file && path.resolve(d.file.fileName) === target && d.start !== undefined)
145
+ .map((d): CheckError => {
146
+ const { line, character } = d.file!.getLineAndCharacterOfPosition(d.start!);
147
+ return {
148
+ stage: 'types',
149
+ message: ts.flattenDiagnosticMessageText(d.messageText, ' '),
150
+ line: line + 1,
151
+ column: character + 1,
152
+ code: `TS${d.code}`,
153
+ };
154
+ });
155
+ return { errors };
156
+ }
157
+
158
+ function parseTime(v: string): number {
159
+ const s = v.trim();
160
+ if (/^-?\d+(\.\d+)?$/.test(s)) {
161
+ const n = Number(s);
162
+ return n > 1e12 ? Math.round(n / 1000) : n;
163
+ }
164
+ const t = Date.parse(s);
165
+ if (Number.isNaN(t)) throw new Error(`Invalid time "${v}"`);
166
+ return Math.round(t / 1000);
167
+ }
168
+
169
+ function parseCsv(text: string): { header: string[]; rows: string[][] } {
170
+ const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '');
171
+ const split = (l: string) => l.split(/[,;\t]/).map((c) => c.trim().replace(/^"(.*)"$/, '$1'));
172
+ return { header: split(lines[0] ?? ''), rows: lines.slice(1).map(split) };
173
+ }
174
+
175
+ const TIME_COLUMNS = ['time', 'timestamp', 'date', 'datetime'];
176
+ const BAR_COLUMNS = ['open', 'high', 'low', 'close', 'volume'];
177
+
178
+ /** Bars from a CSV (time, open, high, low, close[, volume]) or a JSON array of bars, oldest first. */
179
+ export function readBars(file: string): Bar[] {
180
+ const text = readFileSync(file, 'utf8');
181
+ let bars: Bar[];
182
+ if (/\.json$/i.test(file)) {
183
+ const raw = JSON.parse(text) as Array<Record<string, unknown>> | { bars: Array<Record<string, unknown>> };
184
+ const list = Array.isArray(raw) ? raw : raw.bars;
185
+ bars = list.map((b) => ({
186
+ time: parseTime(String(b.time)),
187
+ open: Number(b.open),
188
+ high: Number(b.high),
189
+ low: Number(b.low),
190
+ close: Number(b.close),
191
+ volume: b.volume === undefined ? NaN : Number(b.volume),
192
+ }));
193
+ } else {
194
+ const { header, rows } = parseCsv(text);
195
+ const col = (names: string[]) => header.findIndex((h) => names.includes(h.toLowerCase()));
196
+ const [t, o, h, l, c, v] = [col(TIME_COLUMNS), col(['open']), col(['high']), col(['low']), col(['close']), col(['volume'])];
197
+ if ([t, o, h, l, c].some((k) => k < 0)) throw new Error(`${file}: the columns time, open, high, low, close are needed (found ${header.join(', ')})`);
198
+ bars = rows.map((r) => ({
199
+ time: parseTime(r[t]!),
200
+ open: Number(r[o]),
201
+ high: Number(r[h]),
202
+ low: Number(r[l]),
203
+ close: Number(r[c]),
204
+ volume: v >= 0 ? Number(r[v]) : NaN,
205
+ }));
206
+ }
207
+ return bars.sort((a, b) => a.time - b.time);
208
+ }
209
+
210
+ /** A reference CSV: a time column and one column per plot (id or title); empty, "na" or "NaN": na. The bar columns
211
+ * (open, high, low, close, volume) are skipped. */
212
+ export function readReference(file: string, tolerance?: number): CheckReference {
213
+ const { header, rows } = parseCsv(readFileSync(file, 'utf8'));
214
+ const t = header.findIndex((h) => TIME_COLUMNS.includes(h.toLowerCase()));
215
+ if (t < 0) throw new Error(`${file}: a time column is needed (found ${header.join(', ')})`);
216
+ const columns: Record<string, Array<number | null>> = {};
217
+ header.forEach((name, k) => {
218
+ // the bar columns of a chart export are the bars, not expected values
219
+ if (k === t || BAR_COLUMNS.includes(name.toLowerCase())) return;
220
+ columns[name] = rows.map((r) => {
221
+ const s = (r[k] ?? '').trim();
222
+ return s === '' || /^(na|nan|null)$/i.test(s) ? null : Number(s);
223
+ });
224
+ });
225
+ return { times: rows.map((r) => parseTime(r[t]!)), columns, ...(tolerance !== undefined ? { tolerance } : {}) };
226
+ }
227
+
228
+ /**
229
+ * Behaviour checks from a file: its source is the body of a function of `t` (no import or export), e.g.
230
+ * `t.everyBar("RSI in 0..100", (i) => t.plot("RSI").at(i) <= 100);`. An error in the file names its line.
231
+ */
232
+ export function readChecks(file: string): (t: CheckContext) => void {
233
+ const filename = path.resolve(file);
234
+ const fn = vm.compileFunction(readFileSync(filename, 'utf8'), ['t'], { filename }) as (t: CheckContext) => void;
235
+ return (t) => {
236
+ try {
237
+ fn(t);
238
+ } catch (e) {
239
+ const { line, column } = errorLocation(e, filename);
240
+ const m = e && typeof e === 'object' && 'message' in e ? String((e as Error).message) : String(e);
241
+ throw new Error(line ? `${m} (${file}:${line}${column ? `:${column}` : ''})` : m, { cause: e });
242
+ }
243
+ };
244
+ }
245
+
246
+ /** Checks a script file: type check, then {@link checkScript} with file lines and a time limit. */
247
+ export function checkFile(file: string, options: CheckFileOptions = {}): CheckFileReport {
248
+ const filename = path.resolve(file);
249
+ const source = readFileSync(filename, 'utf8');
250
+ const timeframe = options.timeframe;
251
+ const bars =
252
+ options.bars ??
253
+ (options.count !== undefined ? syntheticBars(options.count, timeframeSeconds(timeframe ?? '60') || 3600) : undefined);
254
+ const report = checkScript(source, {
255
+ ...(bars ? { bars } : {}),
256
+ ...(timeframe ? { timeframe } : {}),
257
+ ...(options.inputs ? { inputs: options.inputs } : {}),
258
+ ...(options.spec ? { spec: options.spec } : {}),
259
+ ...(options.reference ? { reference: options.reference } : {}),
260
+ ...(options.checks ? { checks: options.checks } : {}),
261
+ ...(options.scenarios ? { scenarios: true } : {}),
262
+ compile: compileFile(filename),
263
+ run: withTimeLimit(options.timeout ?? 10000),
264
+ filename,
265
+ inputSweep: options.sweep !== false,
266
+ }) as CheckFileReport;
267
+ report.file = file;
268
+ for (const e of report.errors) if (e.line === 1 && e.column !== undefined) e.column = Math.max(1, e.column - SCRIPT_PREFIX.length);
269
+ if (options.types !== false) {
270
+ const types = typecheckFile(filename, options.cwd ?? path.dirname(filename));
271
+ if ('skipped' in types) report.typesSkipped = types.skipped;
272
+ else if (types.errors.length) {
273
+ report.errors.unshift(...types.errors);
274
+ report.ok = false;
275
+ }
276
+ }
277
+ return report;
278
+ }
279
+
280
+ /** Exit code of a report: 0 clean, 2 warnings only, 1 errors or failed checks. */
281
+ export function exitCode(r: CheckReport): number {
282
+ return !r.ok ? 1 : r.warnings.length ? 2 : 0;
283
+ }
284
+
285
+ /** Command line entry: `oakscript check <file> [options]`; prints the report and returns the exit code. */
286
+ export function main(argv: string[], print: (text: string) => void = (t) => process.stdout.write(t + '\n')): number {
287
+ const args = [...argv];
288
+ if (args[0] === 'check') args.shift();
289
+ if (!args.length || args.includes('--help') || args.includes('-h')) {
290
+ print(USAGE);
291
+ return args.length ? 0 : 1;
292
+ }
293
+ let file: string | undefined;
294
+ const o: CheckFileOptions = {};
295
+ let json = false;
296
+ let referenceFile: string | undefined;
297
+ let exportFile: string | undefined;
298
+ let checksFile: string | undefined;
299
+ let tolerance: number | undefined;
300
+ const specErrors: string[] = [];
301
+ try {
302
+ for (let k = 0; k < args.length; k++) {
303
+ const a = args[k]!;
304
+ const next = (): string => {
305
+ const v = args[++k];
306
+ if (v === undefined) throw new Error(`${a} needs a value`);
307
+ return v;
308
+ };
309
+ if (a === '--spec') {
310
+ const specFile = next();
311
+ const spec = JSON.parse(readFileSync(specFile, 'utf8')) as CheckSpec & {
312
+ $schema?: string;
313
+ reference?: string;
314
+ export?: string;
315
+ tolerance?: number;
316
+ checks?: string;
317
+ scenarios?: boolean;
318
+ };
319
+ specErrors.push(...validateSpec(spec, { file: true }).map((m) => `${specFile}: ${m}`));
320
+ const rel = (f: string) => path.resolve(path.dirname(specFile), f);
321
+ if (typeof spec.reference === 'string') referenceFile ??= rel(spec.reference);
322
+ if (typeof spec.export === 'string') exportFile ??= rel(spec.export);
323
+ if (typeof spec.checks === 'string') checksFile ??= rel(spec.checks);
324
+ if (typeof spec.tolerance === 'number') tolerance ??= spec.tolerance;
325
+ if (spec.scenarios === true) o.scenarios = true;
326
+ for (const k of ['$schema', 'reference', 'export', 'tolerance', 'checks', 'scenarios'] as const) delete spec[k];
327
+ o.spec = spec;
328
+ } else if (a === '--reference') referenceFile = next();
329
+ else if (a === '--export') exportFile = next();
330
+ else if (a === '--checks') checksFile = next();
331
+ else if (a === '--scenarios') o.scenarios = true;
332
+ else if (a === '--tolerance') tolerance = Number(next());
333
+ else if (a === '--bars') o.bars = readBars(next());
334
+ else if (a === '--count') o.count = Number(next());
335
+ else if (a === '--timeframe') o.timeframe = next();
336
+ else if (a === '--inputs') o.inputs = JSON.parse(next()) as Record<string, unknown>;
337
+ else if (a === '--timeout') o.timeout = Number(next());
338
+ else if (a === '--no-types') o.types = false;
339
+ else if (a === '--no-sweep') o.sweep = false;
340
+ else if (a === '--json') json = true;
341
+ else if (a.startsWith('--')) throw new Error(`Unknown option ${a}`);
342
+ else if (file === undefined) file = a;
343
+ else throw new Error(`One script file only (got ${file} and ${a})`);
344
+ }
345
+ if (!file) throw new Error('No script file');
346
+ if (!existsSync(file)) throw new Error(`No file ${file}`);
347
+ if (referenceFile) o.reference = readReference(referenceFile, tolerance);
348
+ if (exportFile) {
349
+ o.bars ??= readBars(exportFile);
350
+ o.reference ??= { ...readReference(exportFile, tolerance), ignoreUnknown: true };
351
+ }
352
+ if (checksFile) o.checks = readChecks(checksFile);
353
+ } catch (e) {
354
+ print(`oakscript check: ${(e as Error).message}\n\n${USAGE}`);
355
+ return 1;
356
+ }
357
+ const report = checkFile(file, o);
358
+ if (specErrors.length) {
359
+ // the keys of a spec file are checked here (checkScript sees the spec without them)
360
+ report.errors = report.errors.filter((e) => e.stage !== 'spec');
361
+ report.errors.push(...specErrors.map((message) => ({ stage: 'spec' as const, message })));
362
+ report.ok = false;
363
+ }
364
+ print(json ? JSON.stringify(report, null, 2) : formatReport(report));
365
+ return exitCode(report);
366
+ }
367
+
368
+ export { formatReport };
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @fileoverview The spec of a check (what the user asked for), described once: {@link validateSpec} rejects unknown
3
+ * keys and wrong values (a misspelt key would otherwise be skipped and the check would pass without checking it), and
4
+ * {@link specJsonSchema} gives the JSON Schema of a spec file (`skills/oakscript/spec.schema.json`).
5
+ *
6
+ * @module check/spec
7
+ */
8
+
9
+ type Field =
10
+ | { kind: 'string' | 'number' | 'boolean' | 'any' | 'string[]'; doc: string }
11
+ | { kind: 'enum'; values: readonly string[]; doc: string }
12
+ | { kind: 'object'; fields: Record<string, Field>; doc: string; required?: readonly string[] }
13
+ | { kind: 'map'; of: Field; doc: string }
14
+ | { kind: 'outputs'; doc: string };
15
+
16
+ const num = (doc: string): Field => ({ kind: 'number', doc });
17
+
18
+ export const INPUT_TYPES = ['int', 'float', 'bool', 'string', 'text_area', 'source', 'color', 'timeframe', 'session', 'time', 'symbol'] as const;
19
+ export const OUTPUT_KINDS = ['plot', 'shape', 'char', 'arrow', 'candle', 'bar', 'hline', 'bgcolor', 'barcolor', 'alertcondition'] as const;
20
+
21
+ /** The keys of a spec checked by checkScript. */
22
+ export const SPEC_FIELDS: Record<string, Field> = {
23
+ kind: { kind: 'enum', values: ['indicator', 'strategy'], doc: 'indicator() or strategy()' },
24
+ title: { kind: 'string', doc: 'the title of the declaration' },
25
+ overlay: { kind: 'boolean', doc: 'true: price pane, false: own pane' },
26
+ inputs: {
27
+ kind: 'map',
28
+ doc: 'inputs that must exist, by id or title',
29
+ of: {
30
+ kind: 'object',
31
+ doc: 'the expected input',
32
+ fields: {
33
+ type: { kind: 'enum', values: INPUT_TYPES, doc: 'input type' },
34
+ defval: { kind: 'any', doc: 'default value' },
35
+ title: { kind: 'string', doc: 'title' },
36
+ min: num('minval'),
37
+ max: num('maxval'),
38
+ options: { kind: 'string[]', doc: 'the choices of a string input' },
39
+ },
40
+ },
41
+ },
42
+ outputs: { kind: 'outputs', doc: 'outputs that must exist: a title, or { title, kind }' },
43
+ plots: {
44
+ kind: 'map',
45
+ doc: 'value checks per plot, by id or title',
46
+ of: {
47
+ kind: 'object',
48
+ doc: 'the expected values',
49
+ fields: { min: num('every value is at least this'), max: num('every value is at most this'), warmup: num('the first value comes at this bar index or before') },
50
+ },
51
+ },
52
+ signals: {
53
+ kind: 'map',
54
+ doc: 'count checks per signal (shape, char, arrow, alertcondition, bgcolor, barcolor), by id or title',
55
+ of: {
56
+ kind: 'object',
57
+ doc: 'the expected count',
58
+ fields: {
59
+ min: num('at least this many bars'),
60
+ max: num('at most this many bars'),
61
+ minShare: num('at least this share of the bars (0 to 1)'),
62
+ maxShare: num('at most this share of the bars (0 to 1)'),
63
+ },
64
+ },
65
+ },
66
+ trades: { kind: 'object', doc: 'closed trades of a strategy', fields: { min: num('at least'), max: num('at most') } },
67
+ };
68
+
69
+ /** The keys of a spec file read by the `oakscript check` command (paths relative to the spec file). */
70
+ export const SPEC_FILE_FIELDS: Record<string, Field> = {
71
+ $schema: { kind: 'string', doc: 'the JSON Schema of the spec (oakscriptjs/skills/oakscript/spec.schema.json)' },
72
+ reference: { kind: 'string', doc: 'CSV of expected values per bar (a time column, one column per plot)' },
73
+ export: { kind: 'string', doc: 'chart export CSV: the bars (time, open, high, low, close, volume) and the expected plot values' },
74
+ tolerance: num('relative tolerance of the reference values (default 1e-10)'),
75
+ checks: { kind: 'string', doc: 'JavaScript file of behaviour checks (the body of a function of `t`)' },
76
+ scenarios: { kind: 'boolean', doc: 'also run on the built-in edge-case bars' },
77
+ };
78
+
79
+ function closest(name: string, names: string[]): string | undefined {
80
+ const dist = (a: string, b: string): number => {
81
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
82
+ for (let i = 1; i <= a.length; i++) {
83
+ let diag = row[0]!;
84
+ row[0] = i;
85
+ for (let j = 1; j <= b.length; j++) {
86
+ const up = row[j]!;
87
+ row[j] = Math.min(up + 1, row[j - 1]! + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
88
+ diag = up;
89
+ }
90
+ }
91
+ return row[b.length]!;
92
+ };
93
+ const n = name.toLowerCase();
94
+ const best = names.map((k) => ({ k, d: dist(n, k.toLowerCase()) })).sort((a, b) => a.d - b.d)[0];
95
+ if (!best) return undefined;
96
+ return best.d <= Math.max(2, Math.floor(name.length / 2)) || best.k.toLowerCase().startsWith(n.slice(0, 3)) ? best.k : undefined;
97
+ }
98
+
99
+ function check(value: unknown, field: Field, path: string, errors: string[]): void {
100
+ const fail = (what: string): void => {
101
+ errors.push(`${path}: ${what}`);
102
+ };
103
+ switch (field.kind) {
104
+ case 'any':
105
+ return;
106
+ case 'string':
107
+ case 'number':
108
+ case 'boolean':
109
+ if (typeof value !== field.kind || (field.kind === 'number' && !Number.isFinite(value))) fail(`expected a ${field.kind}, got ${JSON.stringify(value)}`);
110
+ return;
111
+ case 'string[]':
112
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) fail(`expected an array of strings, got ${JSON.stringify(value)}`);
113
+ return;
114
+ case 'enum':
115
+ if (typeof value !== 'string' || !field.values.includes(value)) fail(`expected one of ${field.values.join(', ')}, got ${JSON.stringify(value)}`);
116
+ return;
117
+ case 'outputs':
118
+ if (!Array.isArray(value)) return fail('expected an array of titles or { title, kind }');
119
+ value.forEach((o, k) => {
120
+ if (typeof o === 'string') return;
121
+ check(o, { kind: 'object', doc: '', required: ['title'], fields: { title: { kind: 'string', doc: '' }, kind: { kind: 'enum', values: OUTPUT_KINDS, doc: '' } } }, `${path}[${k}]`, errors);
122
+ });
123
+ return;
124
+ case 'map':
125
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return fail('expected an object (name -> checks)');
126
+ for (const [k, v] of Object.entries(value)) check(v, field.of, `${path}.${k}`, errors);
127
+ return;
128
+ case 'object': {
129
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return fail('expected an object');
130
+ const keys = Object.keys(field.fields);
131
+ for (const [k, v] of Object.entries(value)) {
132
+ const f = field.fields[k];
133
+ if (!f) {
134
+ const near = closest(k, keys);
135
+ fail(`unknown key "${k}"${near ? ` (did you mean "${near}"?)` : ''}; the keys are ${keys.join(', ')}`);
136
+ } else check(v, f, `${path}.${k}`, errors);
137
+ }
138
+ for (const r of field.required ?? []) if (!(r in (value as object))) fail(`"${r}" is required`);
139
+ return;
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The problems of a spec: unknown keys (with the closest key) and wrong values, one message per problem; empty when the
146
+ * spec is valid. `file`: also accept the keys of a spec file (reference, export, tolerance, checks, scenarios).
147
+ */
148
+ export function validateSpec(spec: unknown, options: { file?: boolean } = {}): string[] {
149
+ const errors: string[] = [];
150
+ const fields = options.file ? { ...SPEC_FIELDS, ...SPEC_FILE_FIELDS } : SPEC_FIELDS;
151
+ check(spec, { kind: 'object', doc: '', fields }, 'spec', errors);
152
+ return errors;
153
+ }
154
+
155
+ function schemaOf(field: Field): Record<string, unknown> {
156
+ const description = field.doc ? { description: field.doc } : {};
157
+ switch (field.kind) {
158
+ case 'any':
159
+ return { ...description };
160
+ case 'string':
161
+ case 'number':
162
+ case 'boolean':
163
+ return { type: field.kind, ...description };
164
+ case 'string[]':
165
+ return { type: 'array', items: { type: 'string' }, ...description };
166
+ case 'enum':
167
+ return { enum: [...field.values], ...description };
168
+ case 'outputs':
169
+ return {
170
+ type: 'array',
171
+ ...description,
172
+ items: {
173
+ oneOf: [
174
+ { type: 'string' },
175
+ { type: 'object', additionalProperties: false, required: ['title'], properties: { title: { type: 'string' }, kind: { enum: [...OUTPUT_KINDS] } } },
176
+ ],
177
+ },
178
+ };
179
+ case 'map':
180
+ return { type: 'object', ...description, additionalProperties: schemaOf(field.of) };
181
+ case 'object':
182
+ return {
183
+ type: 'object',
184
+ ...description,
185
+ additionalProperties: false,
186
+ ...(field.required ? { required: [...field.required] } : {}),
187
+ properties: Object.fromEntries(Object.entries(field.fields).map(([k, f]) => [k, schemaOf(f)])),
188
+ };
189
+ }
190
+ }
191
+
192
+ /** JSON Schema (draft 2020-12) of a spec file of `oakscript check`. */
193
+ export function specJsonSchema(): Record<string, unknown> {
194
+ return {
195
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
196
+ title: 'oakscript check spec',
197
+ description: 'What the user asked for, checked by `oakscript check --spec`: every given entry is checked, an unknown key is an error.',
198
+ ...schemaOf({ kind: 'object', doc: '', fields: { ...SPEC_FIELDS, ...SPEC_FILE_FIELDS } }),
199
+ };
200
+ }
package/src/index.ts CHANGED
@@ -224,7 +224,7 @@ export type { LightweightSeriesDefinitions } from './runtime/adapters/Lightweigh
224
224
  export { SimpleInputAdapter } from './runtime/adapters/SimpleInputAdapter.js';
225
225
 
226
226
  // Version
227
- export const VERSION = '0.13.2';
227
+ export const VERSION = '0.14.0';
228
228
 
229
229
  /**
230
230
  * Library information
@@ -654,6 +654,25 @@ export class Series {
654
654
  * Convert series to time-value pairs for charting
655
655
  * @returns Array of { time, value } objects
656
656
  */
657
+ /**
658
+ * A Series holds the values of all the bars, so JavaScript operators cannot use it: without this guard
659
+ * `close - open` is NaN, `close > open` false and `` `${rsi}` `` "[object Object]", with no error. The
660
+ * conversion throws instead, with the Series method or bar-loop read to use.
661
+ */
662
+ [Symbol.toPrimitive](hint: 'number' | 'string' | 'default'): never {
663
+ if (hint === 'string') {
664
+ throw new TypeError(
665
+ 'A Series is not a text: read the value of one bar inside eachBar(), e.g. str.tostring(c.get(rsi)), ' +
666
+ 'or pass the Series itself to plot() / plotshape().'
667
+ );
668
+ }
669
+ throw new TypeError(
670
+ 'JavaScript operators (+ - * / % ** < > <= >= ==) do not work on a Series: use its methods, ' +
671
+ 'a.add(b) a.sub(b) a.mul(b) a.div(b) a.mod(b) a.pow(b) a.neg(), a.gt(b) a.gte(b) a.lt(b) a.lte(b) a.eq(b) a.neq(b), ' +
672
+ 'a.and(b) a.or(b) a.not(), cond.iff(x, y), a.offset(1) for a[1]; or read numbers inside eachBar() with c.get(a).'
673
+ );
674
+ }
675
+
657
676
  toTimeValuePairs(): Array<{ time: any; value: number }> {
658
677
  const values = this.toArray();
659
678
  const bars = this.dataSource.bars;