oakscriptjs 0.9.5 → 0.9.6

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 (177) hide show
  1. package/dist/cjs/array/index.d.ts +84 -27
  2. package/dist/cjs/array/index.d.ts.map +1 -1
  3. package/dist/cjs/array/index.js +235 -78
  4. package/dist/cjs/array/index.js.map +3 -3
  5. package/dist/cjs/callsite/index.d.ts +81 -2
  6. package/dist/cjs/callsite/index.d.ts.map +1 -1
  7. package/dist/cjs/callsite/index.js +62 -1
  8. package/dist/cjs/callsite/index.js.map +2 -2
  9. package/dist/cjs/color/index.d.ts +7 -3
  10. package/dist/cjs/color/index.d.ts.map +1 -1
  11. package/dist/cjs/color/index.js +6 -1
  12. package/dist/cjs/color/index.js.map +2 -2
  13. package/dist/cjs/index.d.ts +1 -1
  14. package/dist/cjs/index.js +1 -1
  15. package/dist/cjs/index.js.map +1 -1
  16. package/dist/cjs/label/index.d.ts.map +1 -1
  17. package/dist/cjs/label/index.js +2 -1
  18. package/dist/cjs/label/index.js.map +2 -2
  19. package/dist/cjs/linefill/index.d.ts.map +1 -1
  20. package/dist/cjs/linefill/index.js +1 -0
  21. package/dist/cjs/linefill/index.js.map +2 -2
  22. package/dist/cjs/math/index.d.ts +76 -21
  23. package/dist/cjs/math/index.d.ts.map +1 -1
  24. package/dist/cjs/math/index.js +155 -41
  25. package/dist/cjs/math/index.js.map +3 -3
  26. package/dist/cjs/matrix/index.d.ts.map +1 -1
  27. package/dist/cjs/matrix/index.js +42 -36
  28. package/dist/cjs/matrix/index.js.map +2 -2
  29. package/dist/cjs/runtime/series.d.ts +9 -0
  30. package/dist/cjs/runtime/series.d.ts.map +1 -1
  31. package/dist/cjs/runtime/series.js +16 -2
  32. package/dist/cjs/runtime/series.js.map +2 -2
  33. package/dist/cjs/script/index.d.ts +15 -31
  34. package/dist/cjs/script/index.d.ts.map +1 -1
  35. package/dist/cjs/script/index.js +32 -0
  36. package/dist/cjs/script/index.js.map +2 -2
  37. package/dist/cjs/session/calendar.d.ts.map +1 -1
  38. package/dist/cjs/session/calendar.js +15 -2
  39. package/dist/cjs/session/calendar.js.map +2 -2
  40. package/dist/cjs/str/index.d.ts +50 -38
  41. package/dist/cjs/str/index.d.ts.map +1 -1
  42. package/dist/cjs/str/index.js +43 -15
  43. package/dist/cjs/str/index.js.map +2 -2
  44. package/dist/cjs/str/messageformat.d.ts +2 -1
  45. package/dist/cjs/str/messageformat.d.ts.map +1 -1
  46. package/dist/cjs/str/messageformat.js +4 -1
  47. package/dist/cjs/str/messageformat.js.map +2 -2
  48. package/dist/cjs/str/numberformat.d.ts +4 -0
  49. package/dist/cjs/str/numberformat.d.ts.map +1 -1
  50. package/dist/cjs/str/numberformat.js +26 -4
  51. package/dist/cjs/str/numberformat.js.map +3 -3
  52. package/dist/cjs/ta/index.d.ts +86 -43
  53. package/dist/cjs/ta/index.d.ts.map +1 -1
  54. package/dist/cjs/ta/index.js +173 -267
  55. package/dist/cjs/ta/index.js.map +3 -3
  56. package/dist/cjs/ta/running-sum.d.ts +54 -2
  57. package/dist/cjs/ta/running-sum.d.ts.map +1 -1
  58. package/dist/cjs/ta/running-sum.js +132 -52
  59. package/dist/cjs/ta/running-sum.js.map +2 -2
  60. package/dist/cjs/ta-series.d.ts +4 -4
  61. package/dist/cjs/ta-series.d.ts.map +1 -1
  62. package/dist/cjs/ta-series.js +4 -3
  63. package/dist/cjs/ta-series.js.map +2 -2
  64. package/dist/cjs/time/datestring.d.ts +1 -0
  65. package/dist/cjs/time/datestring.d.ts.map +1 -1
  66. package/dist/cjs/time/datestring.js +7 -0
  67. package/dist/cjs/time/datestring.js.map +2 -2
  68. package/dist/cjs/time/index.d.ts.map +1 -1
  69. package/dist/cjs/time/index.js +2 -1
  70. package/dist/cjs/time/index.js.map +2 -2
  71. package/dist/cjs/time/session.d.ts +28 -9
  72. package/dist/cjs/time/session.d.ts.map +1 -1
  73. package/dist/cjs/time/session.js +74 -28
  74. package/dist/cjs/time/session.js.map +2 -2
  75. package/dist/cjs/time/timezone.d.ts +5 -0
  76. package/dist/cjs/time/timezone.d.ts.map +1 -1
  77. package/dist/cjs/time/timezone.js +15 -1
  78. package/dist/cjs/time/timezone.js.map +2 -2
  79. package/dist/esm/array/index.d.ts +84 -27
  80. package/dist/esm/array/index.d.ts.map +1 -1
  81. package/dist/esm/array/index.js +373 -111
  82. package/dist/esm/array/index.js.map +1 -1
  83. package/dist/esm/callsite/index.d.ts +81 -2
  84. package/dist/esm/callsite/index.d.ts.map +1 -1
  85. package/dist/esm/callsite/index.js +145 -3
  86. package/dist/esm/callsite/index.js.map +1 -1
  87. package/dist/esm/color/index.d.ts +7 -3
  88. package/dist/esm/color/index.d.ts.map +1 -1
  89. package/dist/esm/color/index.js +15 -3
  90. package/dist/esm/color/index.js.map +1 -1
  91. package/dist/esm/index.d.ts +1 -1
  92. package/dist/esm/index.js +1 -1
  93. package/dist/esm/label/index.d.ts.map +1 -1
  94. package/dist/esm/label/index.js +2 -1
  95. package/dist/esm/label/index.js.map +1 -1
  96. package/dist/esm/linefill/index.d.ts.map +1 -1
  97. package/dist/esm/linefill/index.js +3 -0
  98. package/dist/esm/linefill/index.js.map +1 -1
  99. package/dist/esm/math/index.d.ts +76 -21
  100. package/dist/esm/math/index.d.ts.map +1 -1
  101. package/dist/esm/math/index.js +227 -61
  102. package/dist/esm/math/index.js.map +1 -1
  103. package/dist/esm/matrix/index.d.ts.map +1 -1
  104. package/dist/esm/matrix/index.js +57 -43
  105. package/dist/esm/matrix/index.js.map +1 -1
  106. package/dist/esm/runtime/series.d.ts +9 -0
  107. package/dist/esm/runtime/series.d.ts.map +1 -1
  108. package/dist/esm/runtime/series.js +18 -1
  109. package/dist/esm/runtime/series.js.map +1 -1
  110. package/dist/esm/script/index.d.ts +15 -31
  111. package/dist/esm/script/index.d.ts.map +1 -1
  112. package/dist/esm/script/index.js +44 -1
  113. package/dist/esm/script/index.js.map +1 -1
  114. package/dist/esm/session/calendar.d.ts.map +1 -1
  115. package/dist/esm/session/calendar.js +22 -2
  116. package/dist/esm/session/calendar.js.map +1 -1
  117. package/dist/esm/str/index.d.ts +50 -38
  118. package/dist/esm/str/index.d.ts.map +1 -1
  119. package/dist/esm/str/index.js +103 -52
  120. package/dist/esm/str/index.js.map +1 -1
  121. package/dist/esm/str/messageformat.d.ts +2 -1
  122. package/dist/esm/str/messageformat.d.ts.map +1 -1
  123. package/dist/esm/str/messageformat.js +7 -2
  124. package/dist/esm/str/messageformat.js.map +1 -1
  125. package/dist/esm/str/numberformat.d.ts +4 -0
  126. package/dist/esm/str/numberformat.d.ts.map +1 -1
  127. package/dist/esm/str/numberformat.js +38 -4
  128. package/dist/esm/str/numberformat.js.map +1 -1
  129. package/dist/esm/ta/index.d.ts +86 -43
  130. package/dist/esm/ta/index.d.ts.map +1 -1
  131. package/dist/esm/ta/index.js +296 -348
  132. package/dist/esm/ta/index.js.map +1 -1
  133. package/dist/esm/ta/running-sum.d.ts +54 -2
  134. package/dist/esm/ta/running-sum.d.ts.map +1 -1
  135. package/dist/esm/ta/running-sum.js +166 -71
  136. package/dist/esm/ta/running-sum.js.map +1 -1
  137. package/dist/esm/ta-series.d.ts +4 -4
  138. package/dist/esm/ta-series.d.ts.map +1 -1
  139. package/dist/esm/ta-series.js +5 -4
  140. package/dist/esm/ta-series.js.map +1 -1
  141. package/dist/esm/time/datestring.d.ts +1 -0
  142. package/dist/esm/time/datestring.d.ts.map +1 -1
  143. package/dist/esm/time/datestring.js +12 -0
  144. package/dist/esm/time/datestring.js.map +1 -1
  145. package/dist/esm/time/index.d.ts.map +1 -1
  146. package/dist/esm/time/index.js +3 -1
  147. package/dist/esm/time/index.js.map +1 -1
  148. package/dist/esm/time/session.d.ts +28 -9
  149. package/dist/esm/time/session.d.ts.map +1 -1
  150. package/dist/esm/time/session.js +113 -42
  151. package/dist/esm/time/session.js.map +1 -1
  152. package/dist/esm/time/timezone.d.ts +5 -0
  153. package/dist/esm/time/timezone.d.ts.map +1 -1
  154. package/dist/esm/time/timezone.js +21 -1
  155. package/dist/esm/time/timezone.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/array/index.ts +352 -127
  158. package/src/callsite/index.ts +147 -3
  159. package/src/color/index.ts +15 -5
  160. package/src/index.ts +1 -1
  161. package/src/label/index.ts +2 -1
  162. package/src/linefill/index.ts +2 -0
  163. package/src/math/index.ts +256 -74
  164. package/src/matrix/index.ts +52 -52
  165. package/src/runtime/series.ts +22 -1
  166. package/src/script/index.ts +53 -1
  167. package/src/session/calendar.ts +22 -2
  168. package/src/str/index.ts +95 -51
  169. package/src/str/messageformat.ts +7 -2
  170. package/src/str/numberformat.ts +39 -4
  171. package/src/ta/index.ts +306 -386
  172. package/src/ta/running-sum.ts +187 -74
  173. package/src/ta-series.ts +8 -7
  174. package/src/time/datestring.ts +10 -0
  175. package/src/time/index.ts +3 -1
  176. package/src/time/session.ts +97 -47
  177. package/src/time/timezone.ts +21 -1
@@ -55,7 +55,7 @@ import type {
55
55
  PlotConfig,
56
56
  ShapeConfig,
57
57
  } from '../runtime/types.js';
58
- import { BarData, Series } from '../runtime/series.js';
58
+ import { BarData, Series, MAX_BARS_BACK, historyError } from '../runtime/series.js';
59
59
  import * as taSeries from '../ta-series.js';
60
60
  import * as colorCore from '../color/index.js';
61
61
  import { inputColorDefault } from '../color/parse.js';
@@ -98,6 +98,7 @@ import {
98
98
  type StrategyTrade,
99
99
  type StrategyVariable,
100
100
  } from '../strategy/index.js';
101
+ import { parsePineSession } from '../time/session.js';
101
102
 
102
103
  /** Everything one run of a script produced. */
103
104
  export interface ScriptRunResult {
@@ -264,6 +265,7 @@ export function executeScript(
264
265
  scriptOptions = options;
265
266
  ctx = freshCollector(inputs);
266
267
  resetDrawings();
268
+ mathCore.resetRandom();
267
269
  try {
268
270
  body();
269
271
  const c = ctx;
@@ -449,6 +451,8 @@ export const hl2 = new Series(ctxBars, (b) => (b.high + b.low) / 2);
449
451
  export const hlc3 = new Series(ctxBars, (b) => (b.high + b.low + b.close) / 3);
450
452
  export const ohlc4 = new Series(ctxBars, (b) => (b.open + b.high + b.low + b.close) / 4);
451
453
  export const hlcc4 = new Series(ctxBars, (b) => (b.high + b.low + b.close + b.close) / 4);
454
+ // built-in price variables keep the whole history (no 5000-bar limit)
455
+ for (const s of [hl2, hlc3, ohlc4, hlcc4]) s.fullHistory = true;
452
456
 
453
457
  const SOURCES: Record<string, Series> = { open, high, low, close, volume, hl2, hlc3, ohlc4, hlcc4 };
454
458
 
@@ -975,11 +979,30 @@ export interface BarContext {
975
979
  * });
976
980
  * ```
977
981
  */
982
+ /** Initial history buffer of a script series and its growth factor (measured, issue #149). */
983
+ const HISTORY_START = 242;
984
+ const HISTORY_GROWTH = 1.618034;
985
+
986
+ /**
987
+ * History buffer of one script series in eachBar(), as PineScript grows it (measured on PineScript, issue #149):
988
+ * the buffer starts with 242 bars; when the largest reference of a bar goes past it, a jump (the reference grew by
989
+ * more than 1 since the previous bar) sets the buffer to the reference, and a step of 1 grows it to
990
+ * round(size * 1.618034); a buffer above 5000 stops the script on the next bar (RE10008), with that size as
991
+ * "bars back". A reference above 5000 stops it at once.
992
+ */
993
+ interface HistoryBuffer {
994
+ size: number;
995
+ previous: number;
996
+ barMax: number;
997
+ }
998
+
978
999
  export function eachBar(fn: (c: BarContext) => number | boolean | void): Series {
979
1000
  collector(); // same lifecycle guard (and error message) as the declaration calls
980
1001
  const b = bars();
981
1002
  const out: number[] = new Array(b.length).fill(NaN);
982
1003
  const cache = new Map<Series, number[]>();
1004
+ const buffers = new Map<Series, HistoryBuffer>();
1005
+ let pendingError: { bar: number; barsBack: number } | null = null;
983
1006
  let idx = 0;
984
1007
  const ctx: BarContext = {
985
1008
  get i() {
@@ -1006,6 +1029,13 @@ export function eachBar(fn: (c: BarContext) => number | boolean | void): Series
1006
1029
  },
1007
1030
  get(src: Series, offset: number = 0): number {
1008
1031
  if (offset < 0) throw new RangeError(`c.get(): the offset cannot be negative (${offset} would read a future bar).`);
1032
+ if (!src.fullHistory && offset > 0) {
1033
+ const k = Math.floor(offset);
1034
+ if (k > MAX_BARS_BACK) throw new Error(historyError(idx, k));
1035
+ let buffer = buffers.get(src);
1036
+ if (!buffer) buffers.set(src, (buffer = { size: HISTORY_START, previous: 0, barMax: 0 }));
1037
+ if (k > buffer.barMax) buffer.barMax = k;
1038
+ }
1009
1039
  let vals = cache.get(src);
1010
1040
  if (!vals) {
1011
1041
  vals = src.toArray();
@@ -1021,9 +1051,21 @@ export function eachBar(fn: (c: BarContext) => number | boolean | void): Series
1021
1051
  };
1022
1052
  try {
1023
1053
  for (idx = 0; idx < b.length; idx++) {
1054
+ if (pendingError && pendingError.bar === idx) throw new Error(historyError(idx, pendingError.barsBack));
1024
1055
  currentBar = idx;
1025
1056
  const r = fn(ctx);
1026
1057
  out[idx] = typeof r === 'number' ? r : typeof r === 'boolean' ? (r ? 1 : 0) : NaN;
1058
+ // grow the history buffers from the largest reference of this bar
1059
+ for (const buffer of buffers.values()) {
1060
+ const k = buffer.barMax;
1061
+ if (k > buffer.size) {
1062
+ const next = k - buffer.previous > 1 ? k : Math.round(buffer.size * HISTORY_GROWTH);
1063
+ if (next > MAX_BARS_BACK && !pendingError) pendingError = { bar: idx + 1, barsBack: next };
1064
+ buffer.size = next;
1065
+ }
1066
+ buffer.previous = k;
1067
+ buffer.barMax = 0;
1068
+ }
1027
1069
  }
1028
1070
  } finally {
1029
1071
  currentBar = -1;
@@ -1467,6 +1509,9 @@ export function inSession(time: number, session: string, timezone?: string): boo
1467
1509
  /** PineScript `str.*`; `format_time` and the dates of `format` use the exchange time zone by default. */
1468
1510
  export const str = {
1469
1511
  ...strCore,
1512
+ // format.mintick rounds to the chart symbol's tick (syminfo.mintick)
1513
+ tostring: ((value: unknown, format?: string, mintick?: number) =>
1514
+ strCore.tostring(value as never, format, format === 'mintick' ? (mintick ?? syminfo.mintick) : mintick)) as typeof strCore.tostring,
1470
1515
  format_time(time: number, format: string = "yyyy-MM-dd'T'HH:mm:ssZ", timezone?: string): string {
1471
1516
  return strCore.format_time(time, format, timezone ?? exchangeTimezone());
1472
1517
  },
@@ -1514,6 +1559,13 @@ function calendarFor(session?: string, timezone?: string): { bars: SessionBars;
1514
1559
  const { bars } = sessionBars();
1515
1560
  if (!session && !timezone) return { bars, test: false };
1516
1561
  const tz = timezone ?? exchangeTimezone();
1562
+ // a session string that is empty or does not start with a digit is the symbol session: "regular" the regular
1563
+ // hours, "extended" and any other name the loaded hours (PineScript)
1564
+ if (session !== undefined && parsePineSession(session) === null) {
1565
+ const name = session.trim();
1566
+ const symbol = name === 'regular' && chartCtx.regularSession ? chartCtx.regularSession : chartCtx.session!;
1567
+ return { bars: new SessionBars(new TradingCalendar(tz, { session: toSpec(symbol).session }, 'symbol'), bars.chart.period), test: true };
1568
+ }
1517
1569
  const spec = session ? { session } : { session: toSpec(chartCtx.session!).session };
1518
1570
  return { bars: new SessionBars(new TradingCalendar(tz, spec, session ? 'pine' : 'symbol'), bars.chart.period), test: true };
1519
1571
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { zonedFields, zonedToUnix } from '../time/timezone.js';
19
+ import { parsePineSession } from '../time/session.js';
19
20
 
20
21
  /** Session information of a symbol (hours, corrections, holidays). */
21
22
  export interface SessionSpec {
@@ -44,6 +45,8 @@ interface Version {
44
45
  periods: Period[];
45
46
  /** Index 1 (Sunday) to 7 (Saturday) */
46
47
  days: boolean[];
48
+ /** PineScript session strings: the periods of each weekday (index 1 to 7), from the "|" sections */
49
+ byDay?: Period[][];
47
50
  }
48
51
 
49
52
  export const toDateNum = (y: number, m: number, d: number): DateNum => y * 10000 + m * 100 + d;
@@ -82,10 +85,26 @@ function parsePeriods(text: string, spec: string): Period[] {
82
85
  });
83
86
  }
84
87
 
88
+ /** A PineScript session string as periods per weekday (rules in time/session.ts). */
89
+ function pineVersion(text: string, from: DateNum): Version {
90
+ const ranges = parsePineSession(text);
91
+ if (ranges === null) throw new SyntaxError(`Session "${text}" is the symbol session`);
92
+ const byDay: Period[][] = Array.from({ length: 8 }, () => []);
93
+ for (const { start, end, days } of ranges) {
94
+ const period: Period =
95
+ start === 0 && end === 0
96
+ ? { start: 0, end: 1440, startOffset: 0, endOffset: 0 }
97
+ : { start, end, startOffset: end <= start ? 1 : 0, endOffset: 0 };
98
+ for (const d of days) byDay[d]!.push(period);
99
+ }
100
+ return { from, periods: [], days: byDay.map((p) => p.length > 0), byDay };
101
+ }
102
+
85
103
  function parseVersion(text: string, from: DateNum, spec: string, rule: 'symbol' | 'pine'): Version {
104
+ if (rule === 'pine') return pineVersion(text, from);
86
105
  const [periodText = '', dayText] = text.trim().split(':');
87
106
  const periods = parsePeriods(periodText, spec);
88
- const allDays = periodText.trim() === '24x7' || (rule === 'pine' && periods.length === 1);
107
+ const allDays = periodText.trim() === '24x7';
89
108
  const digits = dayText ?? (allDays ? '1234567' : '23456');
90
109
  if (!/^[1-7]+$/.test(digits)) throw new SyntaxError(`Invalid session days in "${spec}"`);
91
110
  const days = new Array<boolean>(8).fill(false);
@@ -137,7 +156,8 @@ export class TradingCalendar {
137
156
  periods = correction ?? [];
138
157
  } else if (!this.holidays.has(date)) {
139
158
  const version = [...this.versions].reverse().find((v) => v.from <= date)!;
140
- if (version.days[weekdayOf(date)]) periods = version.periods;
159
+ if (version.byDay) periods = version.byDay[weekdayOf(date)]!;
160
+ else if (version.days[weekdayOf(date)]) periods = version.periods;
141
161
  }
142
162
  const y = Math.floor(date / 10000);
143
163
  const m = Math.floor(date / 100) % 100;
package/src/str/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { formatNumber } from './numberformat.js';
13
13
  import { formatMessage } from './messageformat.js';
14
14
  import { formatDate } from './dateformat.js';
15
+ import { round_to_mintick } from '../math/index.js';
15
16
  import type { int, bool, float, simple_int, simple_string } from '../types/index.js';
16
17
 
17
18
  /**
@@ -34,20 +35,28 @@ export function length(str: simple_string): int {
34
35
  * Converts a value to its string representation (PineScript `str.tostring`).
35
36
  *
36
37
  * @param value - The value to convert (number, boolean, string, etc.)
37
- * @param format - Optional number format: a pattern ("#.##", "#,###.00", "0.00%", "0.00E0") or
38
- * `format.percent` ("percent"), `format.volume` ("volume"), `format.price` / `format.inherit`
38
+ * @param format - Optional number format: a pattern ("#.##", "#,###.00", "0.00%", "0.00E0", "#.##;(#.##)") or
39
+ * `format.percent` ("percent"), `format.volume` ("volume"), `format.mintick` ("mintick"),
40
+ * `format.price` / `format.inherit`
41
+ * @param mintick - Tick size for `format.mintick` (PineScript `syminfo.mintick`, implicit there). Not part of the
42
+ * PineScript signature: the core function cannot see the chart, so pass it, as for `math.round_to_mintick`
39
43
  * @returns String representation of the value
40
44
  *
41
45
  * @remarks
42
46
  * As in PineScript:
43
47
  * - without format, numbers show up to 10 decimals ("0.3333333333"); 1e21 and more use "1E21"
44
- * - patterns round half away from zero on the shortest decimal form: 1.005 with "#.##" gives "1.01"
48
+ * - patterns round half away from zero on the shortest decimal form: 1.005 with "#.##" gives "1.01";
49
+ * at most 16 decimals from 1e-3 up (0.0012345678901234567 with 20 '#' decimals gives "0.0012345678901235")
50
+ * - "pos;neg" patterns: a negative number takes the text of the negative subpattern ("#.##;(#.##)": "(3.5)")
45
51
  * - "#" is an optional digit ("1.5" with "#.##"), "0" a required digit ("1.50" with "0.00"),
46
52
  * "," groups thousands, "%" multiplies by 100, 'text' is literal text
47
53
  * - `format.percent` shows 2 decimals and "%" without multiplying; `format.volume` uses K, M, B, T
48
54
  * - `format.price` and `format.inherit` give "price1" / "inherit1" in PineScript: they are patterns
49
55
  * without digit characters, and oakscriptjs does the same
50
- * - `format.mintick` needs the symbol's tick size and throws here
56
+ * - `format.mintick` rounds to the tick (`math.round_to_mintick`) and shows the tick's decimals ("#.##" for 0.01);
57
+ * it throws without `mintick`
58
+ * - na gives "NaN" ("NaN%" with `format.percent`); an infinity gives "NaN" too ("NaN%" for `format.percent`,
59
+ * "NaNT" for `format.volume`), except `format.mintick`, which gives "Infinity" / "-Infinity"
51
60
  *
52
61
  * @example
53
62
  * ```typescript
@@ -57,9 +66,14 @@ export function length(str: simple_string): int {
57
66
  * str.tostring(12345.678, format.volume) // "12.346K"
58
67
  * ```
59
68
  */
60
- export function tostring(value: any, format?: simple_string): string {
69
+ export function tostring(value: any, format?: simple_string, mintick?: float): string {
61
70
  if (typeof value !== 'number') return String(value);
62
- if (!Number.isFinite(value)) return String(value); // NaN (na), Infinity
71
+ if (Number.isNaN(value)) return format === 'percent' ? 'NaN%' : 'NaN';
72
+ if (!Number.isFinite(value)) {
73
+ // the chart formatter has no infinity: NaN with the format's suffix
74
+ if (format === 'mintick') return String(value);
75
+ return format === 'percent' ? 'NaN%' : format === 'volume' ? 'NaNT' : 'NaN';
76
+ }
63
77
  if (format === undefined || format === '') {
64
78
  if (Math.abs(value) >= 1e21 && Number.isFinite(value)) {
65
79
  return String(value).replace('e+', 'E').replace(/\.0+E/, 'E');
@@ -76,8 +90,16 @@ export function tostring(value: any, format?: simple_string): string {
76
90
  }
77
91
  return formatNumber(value, '#', 'halfUpShortest');
78
92
  }
79
- case 'mintick':
80
- throw new Error('str.tostring(x, format.mintick) needs the symbol tick size (syminfo.mintick), which is not known here');
93
+ case 'mintick': {
94
+ if (mintick === undefined) {
95
+ throw new Error('str.tostring(x, format.mintick) needs the tick size: pass it as str.tostring(x, format.mintick, syminfo.mintick)');
96
+ }
97
+ // mask: one '#' per decimal of the tick ("#.##" for 0.01, "#" for 1)
98
+ const m = /^\d+(?:\.(\d+))?(?:e-(\d+))?$/.exec(String(mintick));
99
+ const decimals = m ? (m[1]?.length ?? 0) + Number(m[2] ?? 0) : 0;
100
+ const mask = decimals ? '#.' + '#'.repeat(decimals) : '#';
101
+ return formatNumber(round_to_mintick(value, mintick), mask, 'halfUpShortest');
102
+ }
81
103
  default:
82
104
  return formatNumber(value, format, 'halfUpShortest');
83
105
  }
@@ -87,26 +109,33 @@ export function tostring(value: any, format?: simple_string): string {
87
109
  * Converts a string to a number (float).
88
110
  *
89
111
  * @param str - The string to convert
90
- * @returns The numeric value, or null if conversion fails
112
+ * @returns The numeric value, or null (na) if conversion fails
91
113
  *
92
114
  * @remarks
93
- * - Returns null for invalid number strings
94
- * - Supports scientific notation (e.g., "1e3")
95
- * - Supports decimal numbers (e.g., "0.5", ".5")
96
- * - Whitespace is ignored
97
- * - "NaN" string returns null
115
+ * As in PineScript:
116
+ * - The whole text must be a number: a sign, digits and one optional "." ("0.5", ".5", "5.", "-12"); "12abc" is na
117
+ * - Scientific notation ("1e3"), "Infinity", "NaN", hexadecimal, "1,000" give na
118
+ * - The characters U+0000 to U+0020 (space, tab, line feed...) around the number are ignored; other spaces
119
+ * (no-break space U+00A0, U+3000) give na
98
120
  *
99
121
  * @example
100
122
  * ```typescript
101
123
  * str.tonumber("123") // Returns: 123
102
124
  * str.tonumber("45.67") // Returns: 45.67
103
125
  * str.tonumber("abc") // Returns: null
104
- * str.tonumber("1e3") // Returns: 1000
126
+ * str.tonumber("12abc") // Returns: null
127
+ * str.tonumber("1e3") // Returns: null
105
128
  * ```
106
129
  */
107
130
  export function tonumber(str: simple_string): float | null {
108
- const num = parseFloat(str);
109
- return isNaN(num) ? null : num;
131
+ // remove the characters U+0000..U+0020 at both ends
132
+ let start = 0;
133
+ let end = str.length;
134
+ while (start < end && str.charCodeAt(start) <= 0x20) start++;
135
+ while (end > start && str.charCodeAt(end - 1) <= 0x20) end--;
136
+ const text = str.slice(start, end);
137
+ if (!/^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(text)) return null;
138
+ return parseFloat(text);
110
139
  }
111
140
 
112
141
  /**
@@ -118,6 +147,7 @@ export function tonumber(str: simple_string): float | null {
118
147
  * @returns The extracted substring
119
148
  *
120
149
  * @remarks
150
+ * - An na `end` (NaN / null) is a runtime error in PineScript: it throws
121
151
  * - If begin > end, indices are swapped (JavaScript behavior)
122
152
  * - Negative indices are treated as 0
123
153
  * - Indices beyond string length return empty string
@@ -130,6 +160,7 @@ export function tonumber(str: simple_string): float | null {
130
160
  * ```
131
161
  */
132
162
  export function substring(str: simple_string, begin: simple_int, end?: simple_int): string {
163
+ if (end === null || Number.isNaN(end)) throw new Error('str.substring: the end position is na');
133
164
  return str.substring(begin, end);
134
165
  }
135
166
 
@@ -194,51 +225,63 @@ export function contains(source: simple_string, str: simple_string): bool {
194
225
  *
195
226
  * @param source - The string to search in
196
227
  * @param str - The substring to search for
197
- * @returns The 0-based index of the first occurrence, or -1 if not found
228
+ * @returns The 0-based index of the first occurrence, or na (NaN) if not found
198
229
  *
199
230
  * @remarks
200
231
  * - Search is case-sensitive
201
232
  * - Returns 0 for empty search string
202
- * - Returns -1 if substring is not found
233
+ * - Returns na (NaN) if substring is not found, as in PineScript
203
234
  *
204
235
  * @example
205
236
  * ```typescript
206
237
  * str.pos("hello world", "world") // Returns: 6
207
238
  * str.pos("hello world", "o") // Returns: 4 (first occurrence)
208
- * str.pos("hello", "xyz") // Returns: -1
239
+ * str.pos("hello", "xyz") // Returns: NaN (na)
209
240
  * ```
210
241
  */
211
242
  export function pos(source: simple_string, str: simple_string): int {
212
- return source.indexOf(str);
243
+ const index = source.indexOf(str);
244
+ return index === -1 ? NaN : index;
213
245
  }
214
246
 
215
247
  /**
216
- * Replaces occurrences of a substring with another string.
248
+ * Replaces the Nth occurrence of a substring (PineScript `str.replace`).
217
249
  *
218
250
  * @param source - The source string
219
251
  * @param target - The substring to replace
220
252
  * @param replacement - The string to replace with
221
- * @param occurrence - If 0, replaces first occurrence only; otherwise replaces all occurrences (default: all)
222
- * @returns The string with replacements made
253
+ * @param occurrence - 0-based occurrence to replace (default 0, the first one)
254
+ * @returns The string with one occurrence replaced
223
255
  *
224
256
  * @remarks
225
- * - Search is case-sensitive
226
- * - If target is not found, returns source unchanged
227
- * - occurrence parameter: 0 = first only, any other value = all occurrences
257
+ * PineScript rules:
258
+ * - only the Nth occurrence is replaced; an na occurrence is 0 (`str.replace("aaa", "a", "-", na)` is "-aa")
259
+ * - occurrences overlap: "aa" occurs at 0 and 1 in "aaa" (`str.replace("aaa", "aa", "-", 1)` is "a-")
260
+ * - an empty target inserts the replacement at character N, at most at the end
261
+ * (`str.replace("abc", "", "-", 2)` is "ab-c")
262
+ * - fewer than N + 1 occurrences (or a negative N) leave the source unchanged
228
263
  *
229
264
  * @example
230
265
  * ```typescript
231
- * str.replace("hello world hello", "hello", "hi", 0) // Returns: "hi world hello"
232
- * str.replace("hello world hello", "hello", "hi") // Returns: "hi world hi"
266
+ * str.replace("hello world hello", "hello", "hi") // Returns: "hi world hello"
267
+ * str.replace("hello world hello", "hello", "hi", 1) // Returns: "hello world hi"
233
268
  * str.replace("test", "xyz", "abc") // Returns: "test"
234
269
  * ```
235
270
  */
236
- export function replace(source: simple_string, target: simple_string, replacement: simple_string, occurrence?: simple_int): string {
237
- if (occurrence === 0) {
238
- return source.replace(target, replacement);
271
+ export function replace(source: simple_string, target: simple_string, replacement: simple_string, occurrence: simple_int = 0): string {
272
+ const n = occurrence === null || Number.isNaN(occurrence) ? 0 : Math.trunc(occurrence);
273
+ if (n < 0) return source;
274
+ let index: number;
275
+ if (target === '') {
276
+ index = Math.min(n, source.length);
239
277
  } else {
240
- return source.replaceAll(target, replacement);
278
+ index = -1;
279
+ for (let k = 0; k <= n; k++) {
280
+ index = source.indexOf(target, index + 1);
281
+ if (index === -1) return source;
282
+ }
241
283
  }
284
+ return source.slice(0, index) + replacement + source.slice(index + target.length);
242
285
  }
243
286
 
244
287
  /**
@@ -274,7 +317,7 @@ export function replace_all(source: simple_string, target: simple_string, replac
274
317
  * @returns Array of substrings
275
318
  *
276
319
  * @remarks
277
- * - Empty separator splits into individual characters
320
+ * - Empty separator splits into individual characters; an empty string gives [""] (also with "")
278
321
  * - If separator is not found, returns array with original string
279
322
  * - Consecutive separators create empty strings in result
280
323
  *
@@ -286,7 +329,8 @@ export function replace_all(source: simple_string, target: simple_string, replac
286
329
  * ```
287
330
  */
288
331
  export function split(str: simple_string, separator: simple_string): string[] {
289
- return str.split(separator);
332
+ // JavaScript gives [] for "".split(""); PineScript gives [""]
333
+ return str === '' ? [''] : str.split(separator);
290
334
  }
291
335
 
292
336
  /**
@@ -316,7 +360,8 @@ export function concat(...strings: simple_string[]): string {
316
360
  * @remarks
317
361
  * As in PineScript: numbers in `{n}` use
318
362
  * "#,##0.###" (1234.5678 gives "1,234.568"); number patterns round half to even on the exact
319
- * value; `'...'` is literal text and `''` a quote; a missing argument leaves `{n}` in the text.
363
+ * value (16 decimals at most from 1e-3 up); an array gives "[a, b]"; `'...'` is literal text and `''` a quote;
364
+ * a missing argument leaves `{n}` in the text.
320
365
  * Dates are formatted in UTC here; PineScript uses the exchange time zone.
321
366
  *
322
367
  * @example
@@ -459,26 +504,25 @@ export function trimRight(str: simple_string): string {
459
504
  }
460
505
 
461
506
  /**
462
- * Tests if a string matches a regular expression pattern.
507
+ * Returns the first substring of `source` that matches the `regex` (PineScript `str.match`).
463
508
  *
464
- * @param source - The string to test
465
- * @param regex - The regular expression pattern
466
- * @returns True if the pattern matches, false otherwise
509
+ * @param source - Source string
510
+ * @param regex - Regular expression
511
+ * @returns The first match (searched anywhere in the string), or na (null) when nothing matches or the match is empty
467
512
  *
468
513
  * @remarks
514
+ * - As PineScript: "Hello World!" with "o" gives "o", with "[a-z]+" gives "ello"; no match gives na
469
515
  * - Uses JavaScript regex syntax
470
- * - Match is case-sensitive by default
471
- * - Pattern is tested against entire string
472
516
  *
473
517
  * @example
474
518
  * ```typescript
475
- * str.match("hello123", "\\d+") // Returns: true
476
- * str.match("test@example.com", "^[a-z]+@[a-z]+\\.[a-z]+$") // Returns: true
477
- * str.match("abc", "[A-Z]+") // Returns: false
519
+ * str.match("abc123def", "[0-9]+") // Returns: "123"
520
+ * str.match("abc", "[0-9]+") // Returns: null (na)
478
521
  * ```
479
522
  */
480
- export function match(source: simple_string, regex: simple_string): bool {
481
- return new RegExp(regex).test(source);
523
+ export function match(source: simple_string, regex: simple_string): simple_string | null {
524
+ const found = new RegExp(regex).exec(source);
525
+ return found && found[0] !== '' ? found[0] : null;
482
526
  }
483
527
 
484
528
  /**
@@ -521,21 +565,21 @@ export function match(source: simple_string, regex: simple_string): bool {
521
565
  * Constructs a new string containing the source string repeated N times with an optional separator.
522
566
  *
523
567
  * @param source - The string to repeat
524
- * @param count - Number of times to repeat (must be >= 0)
568
+ * @param count - Number of times to repeat
525
569
  * @param separator - String to inject between repeated instances. Optional, defaults to empty string.
526
- * @returns The repeated string, or null if source is null/undefined
570
+ * @returns The repeated string, or null (na) if source is na or count is 0 / negative / na, as in PineScript
527
571
  *
528
572
  * @example
529
573
  * ```typescript
530
574
  * str.repeat("ab", 3) // Returns: "ababab"
531
575
  * str.repeat("?", 3, ",") // Returns: "?,?,?"
532
- * str.repeat("hello", 0) // Returns: ""
576
+ * str.repeat("hello", 0) // Returns: null (na)
533
577
  * str.repeat("x", 1) // Returns: "x"
534
578
  * ```
535
579
  */
536
580
  export function repeat(source: simple_string, count: simple_int, separator: simple_string = ''): string | null {
537
581
  if (source == null) return null;
538
- if (count <= 0) return '';
582
+ if (!(count > 0)) return null; // 0, negative or na: na
539
583
  return Array(count).fill(source).join(separator);
540
584
  }
541
585
 
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `str.format` patterns as PineScript formats them (Java MessageFormat):
3
3
  *
4
- * - `{n}` inserts argument n; numbers use "#,##0.###" (1234.5678 gives "1,234.568")
4
+ * - `{n}` inserts argument n; numbers use "#,##0.###" (1234.5678 gives "1,234.568"); an array gives
5
+ * "[Hello, World!]"
5
6
  * - `{n,number}`, `{n,number,integer}` ("#,##0"), `{n,number,percent}` ("#,##0%"),
6
7
  * `{n,number,currency}` ("$1,234.57", "-$7.00"), `{n,number,<pattern>}`
7
8
  * - `{n,date}`, `{n,time}`, `{n,date,<pattern>}`, `{n,time,<pattern>}` (styles short, medium, long, full)
@@ -44,7 +45,11 @@ function formatArgument(
44
45
  timezone: string | (() => string)
45
46
  ): string {
46
47
  if (value === undefined) return '';
47
- if (!type) return typeof value === 'number' ? number(value, '#,##0.###') : String(value);
48
+ if (!type) {
49
+ // an array gives "[a, b]" (PineScript); the element format of numbers is not measured: plain JS text
50
+ if (Array.isArray(value)) return '[' + value.map((v) => (v == null ? 'NaN' : String(v))).join(', ') + ']';
51
+ return typeof value === 'number' ? number(value, '#,##0.###') : String(value);
52
+ }
48
53
  switch (type.trim()) {
49
54
  case 'number': {
50
55
  const s = style?.trim();
@@ -12,6 +12,10 @@
12
12
  * - with an exponent, the value is first rounded to the fraction digits ("0.00E0": 0.125 gives "1.30E-1")
13
13
  * - a pattern without digit characters (`format.price` is "price") formats like "#" after its text,
14
14
  * and keeps the sign: -0.004 gives "-price0"
15
+ * - "pos;neg": a negative number uses the text around the digits of the negative subpattern instead of "-"
16
+ * ("#.##;(#.##)" gives "(3.5)" for -3.5); the digits follow the positive subpattern, as in Java
17
+ * - at most 16 decimals when |x| >= 1e-3 (the shortest digits of a double never have more there):
18
+ * 0.1 with 20 '#' decimals gives "0.1" in both rounding modes
15
19
  */
16
20
 
17
21
  export type Rounding = 'halfUpShortest' | 'halfEvenExact';
@@ -19,6 +23,9 @@ export type Rounding = 'halfUpShortest' | 'halfEvenExact';
19
23
  interface Pattern {
20
24
  prefix: string;
21
25
  suffix: string;
26
+ /** Text around the digits of the negative subpattern ("pos;neg"), or null without one */
27
+ negPrefix: string | null;
28
+ negSuffix: string | null;
22
29
  minInt: number;
23
30
  minFrac: number;
24
31
  maxFrac: number;
@@ -32,9 +39,27 @@ interface Pattern {
32
39
 
33
40
  const patterns = new Map<string, Pattern>();
34
41
 
42
+ /** Index of the first ';' outside quotes, or -1. */
43
+ function subpatternSplit(pattern: string): number {
44
+ let quoted = false;
45
+ for (let i = 0; i < pattern.length; i++) {
46
+ if (pattern[i] === "'") quoted = !quoted;
47
+ else if (pattern[i] === ';' && !quoted) return i;
48
+ }
49
+ return -1;
50
+ }
51
+
35
52
  function parsePattern(pattern: string): Pattern {
36
53
  const cached = patterns.get(pattern);
37
54
  if (cached) return cached;
55
+ const semi = subpatternSplit(pattern);
56
+ if (semi !== -1) {
57
+ // only the text around the digits of the negative subpattern is used
58
+ const neg = parsePattern(pattern.slice(semi + 1));
59
+ const parsed = { ...parsePattern(pattern.slice(0, semi)), negPrefix: neg.prefix, negSuffix: neg.suffix };
60
+ patterns.set(pattern, parsed);
61
+ return parsed;
62
+ }
38
63
 
39
64
  let prefix = '';
40
65
  let suffix = '';
@@ -72,6 +97,8 @@ function parsePattern(pattern: string): Pattern {
72
97
  const parsed: Pattern = {
73
98
  prefix,
74
99
  suffix,
100
+ negPrefix: null,
101
+ negSuffix: null,
75
102
  // as in Java, a pattern with a decimal point and no '0' at all ("#.##") shows one integer digit
76
103
  minInt: !mantissa.includes('0') && mantissa.includes('.') ? 1 : [...intPart].filter((c) => c === '0').length,
77
104
  minFrac: [...fracPart].filter((c) => c === '0').length,
@@ -169,7 +196,7 @@ function layout(d: Digits, p: Pattern): string {
169
196
  export function formatNumber(value: number, pattern: string, mode: Rounding): string {
170
197
  if (Number.isNaN(value)) return 'NaN';
171
198
  const p = parsePattern(pattern);
172
- if (!Number.isFinite(value)) return (value < 0 ? '-' : '') + p.prefix + '∞' + p.suffix;
199
+ if (!Number.isFinite(value)) return affixes(p, value < 0, '∞');
173
200
  const x = p.percent ? value * 100 : value;
174
201
  const toDigits = mode === 'halfUpShortest' ? shortestDigits : exactDigits;
175
202
 
@@ -188,10 +215,18 @@ export function formatNumber(value: number, pattern: string, mode: Rounding): st
188
215
  const e = String(Math.abs(exponent)).padStart(p.exponent, '0');
189
216
  body = layout(mant, p) + 'E' + (exponent < 0 ? '-' : '') + e;
190
217
  } else {
191
- const rounded = roundDigits(toDigits(x), p.hasDigits ? p.maxFrac : 0, mode);
218
+ // 16 decimals at most from 1e-3 up (the shortest digits of a double have no more there)
219
+ const frac = !p.hasDigits ? 0 : Math.abs(x) >= 1e-3 ? Math.min(p.maxFrac, 16) : p.maxFrac;
220
+ const rounded = roundDigits(toDigits(x), frac, mode);
192
221
  isZero = rounded.digits === '';
193
222
  body = p.hasDigits ? layout(rounded, p) : layout(rounded, { ...p, minInt: 0, maxFrac: 0, minFrac: 0 });
194
223
  }
195
- const negative = x < 0 && (!isZero || !p.hasDigits);
196
- return (negative ? '-' : '') + p.prefix + body + p.suffix;
224
+ return affixes(p, x < 0 && (!isZero || !p.hasDigits), body);
225
+ }
226
+
227
+ /** `body` with the text of the positive pattern, or of the negative one ("-" + prefix without "pos;neg"). */
228
+ function affixes(p: Pattern, negative: boolean, body: string): string {
229
+ if (!negative) return p.prefix + body + p.suffix;
230
+ if (p.negPrefix === null) return '-' + p.prefix + body + p.suffix;
231
+ return p.negPrefix + body + p.negSuffix!;
197
232
  }