openalgo-script 0.4.0 → 0.5.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 (158) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/README.md +69 -16
  3. package/dist/adapters/charts/driving.d.ts +50 -0
  4. package/dist/adapters/charts/driving.d.ts.map +1 -0
  5. package/dist/adapters/charts/driving.js +57 -0
  6. package/dist/adapters/charts/driving.js.map +1 -0
  7. package/dist/adapters/charts/run.d.ts +20 -0
  8. package/dist/adapters/charts/run.d.ts.map +1 -1
  9. package/dist/adapters/charts/run.js +83 -16
  10. package/dist/adapters/charts/run.js.map +1 -1
  11. package/dist/adapters/charts/venue.d.ts +73 -0
  12. package/dist/adapters/charts/venue.d.ts.map +1 -0
  13. package/dist/adapters/charts/venue.js +104 -0
  14. package/dist/adapters/charts/venue.js.map +1 -0
  15. package/dist/core/accounting/analysis.d.ts +111 -0
  16. package/dist/core/accounting/analysis.d.ts.map +1 -0
  17. package/dist/core/accounting/analysis.js +123 -0
  18. package/dist/core/accounting/analysis.js.map +1 -0
  19. package/dist/core/accounting/equity.d.ts +33 -0
  20. package/dist/core/accounting/equity.d.ts.map +1 -1
  21. package/dist/core/accounting/equity.js +12 -0
  22. package/dist/core/accounting/equity.js.map +1 -1
  23. package/dist/core/accounting/index.d.ts +2 -0
  24. package/dist/core/accounting/index.d.ts.map +1 -1
  25. package/dist/core/accounting/index.js +1 -0
  26. package/dist/core/accounting/index.js.map +1 -1
  27. package/dist/core/accounting/report.d.ts +3 -0
  28. package/dist/core/accounting/report.d.ts.map +1 -1
  29. package/dist/core/accounting/report.js +2 -0
  30. package/dist/core/accounting/report.js.map +1 -1
  31. package/dist/core/accounting/statistics.d.ts +12 -0
  32. package/dist/core/accounting/statistics.d.ts.map +1 -1
  33. package/dist/core/accounting/statistics.js +23 -1
  34. package/dist/core/accounting/statistics.js.map +1 -1
  35. package/dist/core/backtest/case.d.ts +60 -0
  36. package/dist/core/backtest/case.d.ts.map +1 -0
  37. package/dist/core/backtest/case.js +319 -0
  38. package/dist/core/backtest/case.js.map +1 -0
  39. package/dist/core/backtest/compare.d.ts.map +1 -1
  40. package/dist/core/backtest/compare.js +2 -0
  41. package/dist/core/backtest/compare.js.map +1 -1
  42. package/dist/core/backtest/deliver.d.ts +93 -0
  43. package/dist/core/backtest/deliver.d.ts.map +1 -0
  44. package/dist/core/backtest/deliver.js +94 -0
  45. package/dist/core/backtest/deliver.js.map +1 -0
  46. package/dist/core/backtest/drive.d.ts +66 -8
  47. package/dist/core/backtest/drive.d.ts.map +1 -1
  48. package/dist/core/backtest/drive.js +109 -59
  49. package/dist/core/backtest/drive.js.map +1 -1
  50. package/dist/core/backtest/index.d.ts +24 -12
  51. package/dist/core/backtest/index.d.ts.map +1 -1
  52. package/dist/core/backtest/index.js +21 -10
  53. package/dist/core/backtest/index.js.map +1 -1
  54. package/dist/core/backtest/record.d.ts +63 -2
  55. package/dist/core/backtest/record.d.ts.map +1 -1
  56. package/dist/core/backtest/record.js +71 -4
  57. package/dist/core/backtest/record.js.map +1 -1
  58. package/dist/core/backtest/replay.d.ts.map +1 -1
  59. package/dist/core/backtest/replay.js +11 -1
  60. package/dist/core/backtest/replay.js.map +1 -1
  61. package/dist/core/backtest/simulate.d.ts +113 -1
  62. package/dist/core/backtest/simulate.d.ts.map +1 -1
  63. package/dist/core/backtest/simulate.js +123 -5
  64. package/dist/core/backtest/simulate.js.map +1 -1
  65. package/dist/core/catalogue/catalogue.generated.d.ts +1 -1
  66. package/dist/core/catalogue/catalogue.generated.js +1 -1
  67. package/dist/core/catalogue/catalogue.generated.js.map +1 -1
  68. package/dist/core/check/library-orders.js +2 -2
  69. package/dist/core/check/library-orders.js.map +1 -1
  70. package/dist/core/check/library-prose.generated.js +2 -2
  71. package/dist/core/check/library-prose.generated.js.map +1 -1
  72. package/dist/core/emit/canonical.d.ts +22 -8
  73. package/dist/core/emit/canonical.d.ts.map +1 -1
  74. package/dist/core/emit/canonical.js +67 -6
  75. package/dist/core/emit/canonical.js.map +1 -1
  76. package/dist/core/engine/arithmetic.d.ts +6 -25
  77. package/dist/core/engine/arithmetic.d.ts.map +1 -1
  78. package/dist/core/engine/arithmetic.js +48 -3
  79. package/dist/core/engine/arithmetic.js.map +1 -1
  80. package/dist/core/engine/index.d.ts +1 -1
  81. package/dist/core/engine/index.d.ts.map +1 -1
  82. package/dist/core/engine/index.js +1 -1
  83. package/dist/core/engine/index.js.map +1 -1
  84. package/dist/core/engine/library/arrays.d.ts.map +1 -1
  85. package/dist/core/engine/library/arrays.js +8 -2
  86. package/dist/core/engine/library/arrays.js.map +1 -1
  87. package/dist/core/engine/library/code-points.d.ts +40 -0
  88. package/dist/core/engine/library/code-points.d.ts.map +1 -0
  89. package/dist/core/engine/library/code-points.js +74 -0
  90. package/dist/core/engine/library/code-points.js.map +1 -0
  91. package/dist/core/engine/library/index.d.ts +5 -0
  92. package/dist/core/engine/library/index.d.ts.map +1 -1
  93. package/dist/core/engine/library/index.js +5 -0
  94. package/dist/core/engine/library/index.js.map +1 -1
  95. package/dist/core/engine/library/text.d.ts.map +1 -1
  96. package/dist/core/engine/library/text.js +51 -34
  97. package/dist/core/engine/library/text.js.map +1 -1
  98. package/dist/core/engine/load.d.ts +20 -0
  99. package/dist/core/engine/load.d.ts.map +1 -1
  100. package/dist/core/engine/load.js +62 -0
  101. package/dist/core/engine/load.js.map +1 -1
  102. package/dist/core/engine/verify-tables.d.ts.map +1 -1
  103. package/dist/core/engine/verify-tables.js +41 -0
  104. package/dist/core/engine/verify-tables.js.map +1 -1
  105. package/dist/core/index.d.ts +10 -5
  106. package/dist/core/index.d.ts.map +1 -1
  107. package/dist/core/index.js +8 -3
  108. package/dist/core/index.js.map +1 -1
  109. package/dist/core/stdlib/index.d.ts +1 -1
  110. package/dist/core/stdlib/index.d.ts.map +1 -1
  111. package/dist/core/stdlib/index.js +1 -1
  112. package/dist/core/stdlib/index.js.map +1 -1
  113. package/dist/core/stdlib/maths/index.d.ts +1 -1
  114. package/dist/core/stdlib/maths/index.d.ts.map +1 -1
  115. package/dist/core/stdlib/maths/index.js +1 -1
  116. package/dist/core/stdlib/maths/index.js.map +1 -1
  117. package/dist/core/stdlib/maths/rounding.d.ts +5 -0
  118. package/dist/core/stdlib/maths/rounding.d.ts.map +1 -1
  119. package/dist/core/stdlib/maths/rounding.js +19 -1
  120. package/dist/core/stdlib/maths/rounding.js.map +1 -1
  121. package/dist/core/version/version.generated.d.ts +1 -1
  122. package/dist/core/version/version.generated.js +1 -1
  123. package/package.json +14 -2
  124. package/spec/README.md +2 -1
  125. package/spec/errors.json +3 -3
  126. package/src/adapters/charts/driving.ts +109 -0
  127. package/src/adapters/charts/run.ts +120 -28
  128. package/src/adapters/charts/venue.ts +132 -0
  129. package/src/core/accounting/analysis.ts +188 -0
  130. package/src/core/accounting/equity.ts +38 -0
  131. package/src/core/accounting/index.ts +2 -0
  132. package/src/core/accounting/report.ts +5 -0
  133. package/src/core/accounting/statistics.ts +38 -1
  134. package/src/core/backtest/case.ts +395 -0
  135. package/src/core/backtest/compare.ts +2 -0
  136. package/src/core/backtest/deliver.ts +161 -0
  137. package/src/core/backtest/drive.ts +175 -71
  138. package/src/core/backtest/index.ts +24 -12
  139. package/src/core/backtest/record.ts +134 -5
  140. package/src/core/backtest/replay.ts +11 -1
  141. package/src/core/backtest/simulate.ts +201 -6
  142. package/src/core/catalogue/catalogue.generated.ts +1 -1
  143. package/src/core/check/library-orders.ts +2 -2
  144. package/src/core/check/library-prose.generated.ts +2 -2
  145. package/src/core/emit/canonical.ts +67 -9
  146. package/src/core/engine/arithmetic.ts +23 -3
  147. package/src/core/engine/index.ts +1 -1
  148. package/src/core/engine/library/arrays.ts +8 -2
  149. package/src/core/engine/library/code-points.ts +73 -0
  150. package/src/core/engine/library/index.ts +6 -0
  151. package/src/core/engine/library/text.ts +53 -35
  152. package/src/core/engine/load.ts +68 -0
  153. package/src/core/engine/verify-tables.ts +43 -0
  154. package/src/core/index.ts +16 -2
  155. package/src/core/stdlib/index.ts +1 -0
  156. package/src/core/stdlib/maths/index.ts +1 -0
  157. package/src/core/stdlib/maths/rounding.ts +23 -1
  158. package/src/core/version/version.generated.ts +1 -1
@@ -25,9 +25,16 @@
25
25
  * is reported on bar k + 1. A frame answered after the last bar has no boundary
26
26
  * left to fold at: it is not delivered, it is not recorded as delivered, and
27
27
  * the order it was about is in the ledger at whatever it last said.
28
+ *
29
+ * **Two drivers, and the only difference between them is where the frames come
30
+ * from.** `backtest` runs against the simulated destination, which decides a
31
+ * fill from the bars and answers frames of its own; `backtestSupplied` is
32
+ * handed the frames and delivers those. Everything else about the two is one
33
+ * function below: the same window, the same load, the same refusals, the same
34
+ * loop and the same record.
28
35
  */
29
36
  import { reportOf, scheduleFromDeclaration } from '../accounting/index.js';
30
- import type { ChargeSchedule, RecordedFill } from '../accounting/index.js';
37
+ import type { ChargeSchedule, Contract, RecordedFill } from '../accounting/index.js';
31
38
  import type { Diagnostic } from '../diagnostics/index.js';
32
39
  import type { CompiledProgram } from '../emit/index.js';
33
40
  import { load } from '../engine/index.js';
@@ -37,12 +44,13 @@ import type {
37
44
  EngineHost,
38
45
  Instrument,
39
46
  LedgerRow,
40
- OrderFrame,
41
47
  OrderIntent,
42
48
  RoutedEffect,
43
49
  } from '../engine/index.js';
44
50
  import { declarationOf } from './declaration.js';
45
51
  import type { RunDeclaration } from './declaration.js';
52
+ import { Delivery, framedAs, ordinalsOf } from './deliver.js';
53
+ import type { Delivered, Destination } from './deliver.js';
46
54
  import { marksFor, windowFor } from './range.js';
47
55
  import type { ReportWindow } from './range.js';
48
56
  import { diagnosticIn, orderIn, recordOf } from './record.js';
@@ -62,14 +70,114 @@ export type BacktestResult =
62
70
  | { readonly ok: true; readonly record: RunRecord }
63
71
  | { readonly ok: false; readonly diagnostic: Diagnostic };
64
72
 
73
+ /**
74
+ * The facts of `host-interface.md` 4.1 that the contract does not hold.
75
+ *
76
+ * The contract already states six of the twelve, the ones the money is priced
77
+ * under, and a fact stated in two places is a fact that can disagree with
78
+ * itself: a tick size the engine rounds to and a different one the venue
79
+ * worsens a fill by is a run under two instruments. So the other six are
80
+ * stated here and the six the contract holds cannot be, which the compiler
81
+ * enforces rather than a check at run time.
82
+ */
83
+ export type InstrumentFacts = Omit<
84
+ Instrument,
85
+ 'symbol' | 'exchange' | 'tickSize' | 'lotSize' | 'pointValue' | 'currency'
86
+ >;
87
+
65
88
  /** The few choices a driver has that are not settings of the run itself. */
66
89
  export interface DriveOptions {
67
90
  /** Whether the bars travel in the record. Inline is the conformance form. */
68
91
  readonly form?: 'inline' | 'referenced';
92
+ /**
93
+ * What the host states about the instrument beside the contract, so the
94
+ * record it produces is a conformance case.
95
+ *
96
+ * A case holds `instrument.json`, which `conformance.md` section 2 says is
97
+ * the record of `host-interface.md` 4.1, and that page requires one fact of
98
+ * every host: `hasVolume`, because no derivation recovers it (4.2). The
99
+ * engine reads the record and does not refuse a run without it, so a caller
100
+ * that leaves this out gets a record that replays and reruns like any other
101
+ * and cannot become a case, exactly as one without `sourceText` cannot.
102
+ *
103
+ * A session stated without a timezone is refused at load, OS6012, by the
104
+ * same rule that refuses it for any host.
105
+ */
106
+ readonly instrument?: InstrumentFacts;
107
+ /**
108
+ * The script's own text, so the record it produces is a conformance case.
109
+ *
110
+ * A compiled program identifies its source and cannot reproduce it, and a
111
+ * case has to hold `script.os`. A caller holding only a program leaves this
112
+ * out and gets a record that replays and reruns like any other; the one
113
+ * thing it cannot do is be handed to somebody else's engine as a case.
114
+ *
115
+ * It is checked against the program's own source hash, so text from a
116
+ * different revision than the one that was compiled is refused rather than
117
+ * written into a case that could never make its own expected output.
118
+ */
119
+ readonly sourceText?: string;
69
120
  }
70
121
 
71
122
  /**
72
- * One run, from a compiled program to a record of it.
123
+ * One run against a simulated destination, from a compiled program to a record.
124
+ *
125
+ * The frames are the destination's own: it is handed the orders, it reads the
126
+ * bars, and what it answers is what the engine folds. A run whose frames
127
+ * somebody else is supplying is `backtestSupplied` below.
128
+ */
129
+ export function backtest(
130
+ program: CompiledProgram,
131
+ bars: readonly RecordedBar[],
132
+ settings: BacktestSettings,
133
+ options: DriveOptions = {},
134
+ ): BacktestResult {
135
+ return drive(program, bars, settings, options, (declared, schedule) =>
136
+ simulated(
137
+ new Simulator({
138
+ bars,
139
+ contract: settings.contract,
140
+ fill: settings.fill,
141
+ slippageTicks: schedule.slippageTicks,
142
+ fillOn: declared.fillOn,
143
+ qtyType: declared.qtyType,
144
+ }),
145
+ ),
146
+ );
147
+ }
148
+
149
+ /**
150
+ * One run over the frames somebody else supplied, which this engine does not
151
+ * choose and does not add to.
152
+ *
153
+ * `conformance.md` section 3 gives a case a `frames.csv` that supplies order
154
+ * frames the way `bars.csv` supplies bars, so that a case asserts the fold
155
+ * against input the engine did not choose. Every other argument is `backtest`'s
156
+ * and means there what it means here. A row is delivered at the boundary it
157
+ * names and folded before the next bar, and the record carries the rows it was
158
+ * handed rather than a second spelling of them.
159
+ *
160
+ * **Nothing is invented.** No fill is priced off a bar, no order rests and no
161
+ * schedule is read, so an order the frames say nothing about stays where its
162
+ * placement left it, which is what a case saying nothing about it means.
163
+ *
164
+ * **A record made here is a record of this run and not of a simulated one.**
165
+ * `rerun` puts a record's program back on a simulated destination, because a
166
+ * record does not say which of the two answered it, so a rerun of one of these
167
+ * reproduces it only where that destination would answer these same frames.
168
+ */
169
+ export function backtestSupplied(
170
+ program: CompiledProgram,
171
+ bars: readonly RecordedBar[],
172
+ settings: BacktestSettings,
173
+ frames: readonly RecordedFrame[],
174
+ options: DriveOptions = {},
175
+ ): BacktestResult {
176
+ return drive(program, bars, settings, options, () => new Delivery(frames));
177
+ }
178
+
179
+ /**
180
+ * The run both drivers are, and the one place a record is made.
73
181
  *
74
182
  * A refusal before the first bar comes back as a refusal and not as a record:
75
183
  * a program that would not load, a setting that cannot be applied and a window
@@ -77,24 +185,28 @@ export interface DriveOptions {
77
185
  * be a document asserting figures nothing computed. A failure during a bar is
78
186
  * the other way round: the run happened, it stopped, and the record carries
79
187
  * both what it did and the diagnostic that stopped it.
188
+ *
189
+ * **The destination is chosen after the program is loaded**, which is why it
190
+ * arrives as a function of the declaration and the schedule: the bar a market
191
+ * order is priced at is the declaration's, and the declaration is resolved at
192
+ * load. The route is wired first and reaches whatever is chosen through the
193
+ * binding below, which is what lets one host serve both halves.
80
194
  */
81
- export function backtest(
195
+ function drive(
82
196
  program: CompiledProgram,
83
197
  bars: readonly RecordedBar[],
84
198
  settings: BacktestSettings,
85
- options: DriveOptions = {},
199
+ options: DriveOptions,
200
+ choose: (declared: RunDeclaration, schedule: ChargeSchedule) => Destination,
86
201
  ): BacktestResult {
87
202
  const framed = windowFor(bars, settings.range);
88
203
  if (!framed.ok) return { ok: false, diagnostic: framed.diagnostic };
89
204
 
90
- // The venue is built after the program is loaded, because the bar a market
91
- // order is priced at is the declaration's and the declaration is resolved at
92
- // load. The route is wired first and reaches it through this binding, which
93
- // is what lets one host serve both halves.
94
- let venue: Simulator | undefined;
205
+ let sending: Destination | undefined;
206
+ const instrument = instrumentFor(settings.contract, options.instrument ?? {});
95
207
  const loaded = load(program, {
96
208
  settings: settings.inputs,
97
- host: hostFor(settings, (effect, bar) => venue?.route(effect, bar)),
209
+ host: hostFor(instrument, settings.now, (effect, bar) => sending?.route(effect, bar)),
98
210
  });
99
211
  if (!loaded.ok) return { ok: false, diagnostic: loaded.diagnostic };
100
212
 
@@ -104,39 +216,42 @@ export function backtest(
104
216
  if (problem !== null) return { ok: false, diagnostic: problem };
105
217
 
106
218
  const schedule = scheduleFor(settings, declared);
107
- venue = new Simulator({
108
- bars,
109
- contract: settings.contract,
110
- fill: settings.fill,
111
- slippageTicks: schedule.slippageTicks,
112
- fillOn: declared.fillOn,
113
- qtyType: declared.qtyType,
114
- });
219
+ const destination = choose(declared, schedule);
220
+ sending = destination;
115
221
 
116
- const run = walk(engine, venue, bars, framed.covered);
117
- const ordinals = ordinalsOf(venue.intents);
222
+ const run = walk(engine, destination, bars, framed.covered);
223
+ const ordinals = ordinalsOf(destination.intents);
118
224
  const marks = marksFor(bars, framed.covered);
119
225
 
120
226
  return {
121
227
  ok: true,
122
228
  record: recordOf({
123
229
  program: engine.program,
230
+ ...(options.sourceText === undefined ? {} : { sourceText: options.sourceText }),
124
231
  settings,
232
+ instrument,
125
233
  bars,
126
234
  form: options.form ?? 'inline',
127
- frames: run.frames.map((one) => framedAs(one.frame, one.afterBar, ordinals)),
235
+ // The row where a case supplied one, because that row is the input and a
236
+ // record of a run over it says what it was given rather than what it
237
+ // would have written down had it decided the frame itself.
238
+ frames: run.frames.map((one) => one.row ?? framedAs(one.frame, one.afterBar, ordinals)),
128
239
  fills: run.fills,
129
- orders: ordersOf(engine.orders(), venue.intents, ordinals),
240
+ orders: ordersOf(engine.orders(), destination.intents, ordinals),
130
241
  diagnostics: run.diagnostics,
131
242
  report: reportOf(run.fills, marks, schedule, settings.contract, declared.capital),
132
243
  }),
133
244
  };
134
245
  }
135
246
 
136
- /** A frame, and the boundary it was handed over at. */
137
- interface Delivered {
138
- readonly frame: OrderFrame;
139
- readonly afterBar: number;
247
+ /** The simulated destination, as a destination the loop below can drive. */
248
+ function simulated(venue: Simulator): Destination {
249
+ return {
250
+ route: (effect, barIndex) => venue.route(effect, barIndex),
251
+ answers: (barIndex) =>
252
+ venue.framesFor(barIndex).map((frame) => ({ frame, afterBar: barIndex, row: null })),
253
+ intents: venue.intents,
254
+ };
140
255
  }
141
256
 
142
257
  /** What one walk of the bars produced. */
@@ -155,7 +270,7 @@ interface Walked {
155
270
  */
156
271
  function walk(
157
272
  engine: Engine,
158
- venue: Simulator,
273
+ destination: Destination,
159
274
  bars: readonly RecordedBar[],
160
275
  covered: ReportWindow,
161
276
  ): Walked {
@@ -196,7 +311,7 @@ function walk(
196
311
  break;
197
312
  }
198
313
 
199
- pending = venue.framesFor(index).map((frame) => ({ frame, afterBar: index }));
314
+ pending = destination.answers(index);
200
315
  }
201
316
 
202
317
  return { fills, frames, diagnostics };
@@ -273,38 +388,6 @@ function intentsIn(result: BarResult): readonly OrderIntent[] {
273
388
  return out;
274
389
  }
275
390
 
276
- /**
277
- * The ordinal of every intent, which is how a case names one.
278
- *
279
- * One, two, three in the order the run placed them, because no engine can know
280
- * the id another minted and a case that named one would only ever be readable
281
- * by the engine that wrote it.
282
- */
283
- function ordinalsOf(intents: readonly OrderIntent[]): ReadonlyMap<number, number> {
284
- const out = new Map<number, number>();
285
- for (const intent of intents) {
286
- if (!out.has(intent.intentId)) out.set(intent.intentId, out.size + 1);
287
- }
288
- return out;
289
- }
290
-
291
- /** One delivered frame, in the columns a case file prints. */
292
- function framedAs(
293
- frame: OrderFrame,
294
- afterBar: number,
295
- ordinals: ReadonlyMap<number, number>,
296
- ): RecordedFrame {
297
- return {
298
- afterBar,
299
- intent: ordinals.get(frame.intentId) ?? 0,
300
- status: frame.status,
301
- filledQty: frame.filledQty,
302
- avgFillPrice: frame.avgFillPrice ?? null,
303
- orderRef: frame.orderRef ?? null,
304
- text: frame.text ?? null,
305
- };
306
- }
307
-
308
391
  /** The ledger at the end, copied out of the engine's own array. */
309
392
  function ordersOf(
310
393
  rows: readonly LedgerRow[],
@@ -336,6 +419,34 @@ function scheduleFor(settings: BacktestSettings, declared: RunDeclaration): Char
336
419
  );
337
420
  }
338
421
 
422
+ /**
423
+ * The instrument record of `host-interface.md` 4.1 the engine is handed, and
424
+ * the record carries verbatim.
425
+ *
426
+ * Composed once, here, from the contract's six facts and the six stated beside
427
+ * it, so the engine and the record read one document and there is no second
428
+ * composition for the two to disagree by. A fact nobody stated is left out
429
+ * rather than written as undefined: the record travels as JSON, which drops an
430
+ * undefined member, and a document that changes shape in transit is not the
431
+ * document the engine read.
432
+ */
433
+ function instrumentFor(contract: Contract, facts: InstrumentFacts): Instrument {
434
+ return {
435
+ ...(contract.symbol === null ? {} : { symbol: contract.symbol }),
436
+ ...(contract.exchange === null ? {} : { exchange: contract.exchange }),
437
+ ...(facts.interval === undefined ? {} : { interval: facts.interval }),
438
+ ...(facts.timezone === undefined ? {} : { timezone: facts.timezone }),
439
+ ...(contract.tickSize === null ? {} : { tickSize: contract.tickSize }),
440
+ ...(contract.lotSize === null ? {} : { lotSize: contract.lotSize }),
441
+ pointValue: contract.pointValue,
442
+ currency: contract.currency,
443
+ ...(facts.instrumentType === undefined ? {} : { instrumentType: facts.instrumentType }),
444
+ ...(facts.hasVolume === undefined ? {} : { hasVolume: facts.hasVolume }),
445
+ ...(facts.hasOpenInterest === undefined ? {} : { hasOpenInterest: facts.hasOpenInterest }),
446
+ ...(facts.session === undefined ? {} : { session: facts.session }),
447
+ };
448
+ }
449
+
339
450
  /**
340
451
  * The host a backtest is: an instrument record, a clock and a destination.
341
452
  *
@@ -345,20 +456,13 @@ function scheduleFor(settings: BacktestSettings, declared: RunDeclaration): Char
345
456
  * with nothing in it.
346
457
  */
347
458
  function hostFor(
348
- settings: BacktestSettings,
459
+ instrument: Instrument,
460
+ now: number | null,
349
461
  route: (effect: RoutedEffect, bar: number) => void,
350
462
  ): EngineHost {
351
- const instrument: Instrument = {
352
- ...(settings.contract.symbol === null ? {} : { symbol: settings.contract.symbol }),
353
- ...(settings.contract.exchange === null ? {} : { exchange: settings.contract.exchange }),
354
- ...(settings.contract.tickSize === null ? {} : { tickSize: settings.contract.tickSize }),
355
- ...(settings.contract.lotSize === null ? {} : { lotSize: settings.contract.lotSize }),
356
- pointValue: settings.contract.pointValue,
357
- currency: settings.contract.currency,
358
- };
359
463
  return {
360
464
  instrument,
361
- ...(settings.now === null ? {} : { now: settings.now }),
465
+ ...(now === null ? {} : { now }),
362
466
  route,
363
467
  };
364
468
  }
@@ -14,18 +14,28 @@
14
14
  * worsened by a tick and a fill is charged. The engine never adjusts a price and
15
15
  * this module never folds one.
16
16
  *
17
- * `backtest` drives one, `replay` folds a stored one's money again from its own
18
- * fills, and `rerun` executes a stored one again and is held to producing the
19
- * same bytes. `recordToJson` and `recordFromJson` are the document itself.
20
- * `compareRuns` puts two records beside each other and says whether the gap
21
- * between them clears the noise. `caseFilesFrom` is named by the design and is
22
- * not here yet: it will return text and write nothing, because core does no
23
- * I/O, and it needs a channel the record does not carry today. `conformance.md`
24
- * section 2 requires a case to hold `script.os`, the source text, and a record
25
- * carries only the source's hash, its line count and its file name.
17
+ * `backtest` drives one against a simulated destination and `backtestSupplied`
18
+ * drives one over frames somebody else supplied, which is what `conformance.md`
19
+ * section 3 asks of an engine running a case. `replay` folds a stored one's
20
+ * money again from its own fills, and `rerun` executes a stored one again and
21
+ * is held to producing the same bytes. `recordToJson` and `recordFromJson` are
22
+ * the document itself. `compareRuns` puts two records beside each other and
23
+ * says whether the gap between them clears the noise. `caseFilesFrom` turns a
24
+ * record into the files of a conformance case, returning text and writing
25
+ * nothing because core does no I/O. It needed three channels the record did not
26
+ * carry. `conformance.md` section 2 requires a case to hold `script.os`, the
27
+ * source text, and a record carried only the source's hash, its line count and
28
+ * its file name: record version 2 carries the text, checked against that hash.
29
+ * The same section says `instrument.json` is the record of `host-interface.md`
30
+ * 4.1, and a record carried the money layer's contract, which holds six of its
31
+ * twelve facts: record version 3 carries the record the engine was handed,
32
+ * whole. And a case is handed frames, which `stdlib.md` 17.7 folds one ledger
33
+ * field from an instant of: a record carried a frame without the instant it
34
+ * arrived at, so a case projected from it asserted a field its own input could
35
+ * not reproduce. Record version 4 carries a frame's time.
26
36
  */
27
- export { backtest } from './drive.js';
28
- export type { BacktestResult, DriveOptions } from './drive.js';
37
+ export { backtest, backtestSupplied } from './drive.js';
38
+ export type { BacktestResult, DriveOptions, InstrumentFacts } from './drive.js';
29
39
  export { declarationOf } from './declaration.js';
30
40
  export type { RunDeclaration } from './declaration.js';
31
41
  export { marksFor, windowFor } from './range.js';
@@ -35,8 +45,10 @@ export type { ReplayResult } from './replay.js';
35
45
  export { testResting } from './resting.js';
36
46
  export type { RestOutcome, RestingOrder } from './resting.js';
37
47
  export { Simulator } from './simulate.js';
38
- export type { SimulatorOptions } from './simulate.js';
48
+ export type { SimulatorOptions, VenueAct, VenueDoes, VenuePolicy } from './simulate.js';
39
49
  export { DEFAULT_FILL, EXACT, WHOLE_RANGE, checkSettings, settingsFor } from './settings.js';
50
+ export { caseFilesFrom } from './case.js';
51
+ export type { CaseFiles, CaseIdentity, CaseResult } from './case.js';
40
52
  export { RECORD_VERSION, barsHash, recordFromJson, recordOf, recordToJson, runBytes } from './record.js';
41
53
  export type { RecordParts } from './record.js';
42
54
  export type { BacktestSettings, DateRange, FillPolicy, Tolerance } from './settings.js';
@@ -26,9 +26,9 @@
26
26
  */
27
27
  import type { RecordedFill, Report } from '../accounting/index.js';
28
28
  import type { Diagnostic } from '../diagnostics/index.js';
29
- import { canonicalise, programHash, sha256 } from '../emit/index.js';
29
+ import { canonicalNumber, canonicalise, programHash, sha256, sourceHash } from '../emit/index.js';
30
30
  import type { CompiledProgram, SourceStamp } from '../emit/index.js';
31
- import type { LedgerRow } from '../engine/index.js';
31
+ import type { Instrument, LedgerRow } from '../engine/index.js';
32
32
  import { VERSION } from '../version/index.js';
33
33
  import type { BacktestSettings } from './settings.js';
34
34
 
@@ -75,6 +75,20 @@ export interface RecordedFrame {
75
75
  readonly avgFillPrice: number | null;
76
76
  readonly orderRef: string | null;
77
77
  readonly text: string | null;
78
+ /**
79
+ * The destination's own instant for this frame, or null where it stated none.
80
+ *
81
+ * **One field of the ledger is folded from it.** `host-interface.md` 7.2
82
+ * gives a frame a `time` and `stdlib.md` 17.7 moves `updatedAt` to it, so a
83
+ * record that dropped it wrote a ledger no engine reading the case back could
84
+ * fold to: handed frames with no instant, that engine leaves every
85
+ * `updatedAt` at `placedAt`, and the two disagree on exactly the rows whose
86
+ * destination answered later than the bar that placed the order.
87
+ *
88
+ * Absent rather than substituted, because 7.2 lets a destination state none
89
+ * and a row whose frame stated none keeps the instant it had.
90
+ */
91
+ readonly time: number | null;
78
92
  }
79
93
 
80
94
  /** A ledger row of `stdlib.md` 17.7, flattened: the `orders` channel of expected.json. */
@@ -121,7 +135,43 @@ export interface RunRecord {
121
135
  readonly programHash: string;
122
136
  /** Hash, lines, file: the script revision. */
123
137
  readonly source: SourceStamp;
138
+ /**
139
+ * The source text itself, or null on a record written before version 2.
140
+ *
141
+ * **A record is the conformance case, and a case has to hold `script.os`.**
142
+ * `source` above identifies the script and cannot reproduce it: a hash is a
143
+ * fingerprint, so it settles whether two files are the same and yields
144
+ * neither of them. Without the text a stored record could be replayed months
145
+ * later and still not be handed to anybody else to run, which is the whole
146
+ * claim the suite exists to test.
147
+ *
148
+ * It lives here and not on the compiled program on purpose. A program is
149
+ * executable data that no engine needs the source to run, and it is the
150
+ * versioned artefact adopters depend on; putting the text there would send a
151
+ * script everywhere its program travels and widen the format every engine
152
+ * has to read. The record is the thing that wants to be self-contained.
153
+ */
154
+ readonly sourceText: string | null;
124
155
  readonly settings: BacktestSettings;
156
+ /**
157
+ * The instrument record of `host-interface.md` 4.1, as the engine was handed
158
+ * it, or null on a record written before version 3.
159
+ *
160
+ * **A case holds `instrument.json`, and `conformance.md` section 2 says that
161
+ * file is this record.** The contract in `settings` is the money layer's
162
+ * snapshot of the same instrument and holds six of the twelve facts; the
163
+ * ones a script reads and the money never does, the interval, the timezone,
164
+ * the session and the volume flag, were handed to the engine and written
165
+ * down nowhere. A case harvested from such a record either omitted a fact
166
+ * the page requires or stated one the run never had, and either way the
167
+ * second engine ran a different study than the one the expected output
168
+ * came from.
169
+ *
170
+ * It is the whole record and not the six facts beside the contract, because
171
+ * what is recorded is what the engine read at load, verbatim, and a reader
172
+ * should not have to compose it.
173
+ */
174
+ readonly instrument: Instrument | null;
125
175
  readonly bars: BarsInRecord;
126
176
  /** What the destination answered, in delivery order. */
127
177
  readonly frames: readonly RecordedFrame[];
@@ -141,7 +191,22 @@ export interface RunRecord {
141
191
  * file alone. It moves when a channel is added or a meaning changes, never when
142
192
  * a figure in a report does.
143
193
  */
144
- export const RECORD_VERSION = 1;
194
+ export const RECORD_VERSION = 4;
195
+
196
+ /**
197
+ * The revision each later channel arrived in.
198
+ *
199
+ * A record written before a channel existed reads with that channel absent,
200
+ * and this table is what `recordFromJson` reads it from: one row per channel
201
+ * added since version 1, so the rule for an old record is stated once and
202
+ * grows by a line when the next channel does.
203
+ *
204
+ * `frameTime` is a field of a row rather than a channel of the document, and it
205
+ * is a row here for the same reason the other two are: what a reader has to
206
+ * know is which revision it arrived in. Where the absence is written differs,
207
+ * and that is the reader's business below, not this table's.
208
+ */
209
+ const ADDED_IN = { sourceText: 2, instrument: 3, frameTime: 4 } as const;
145
210
 
146
211
  /** What this engine calls itself in a record it wrote. */
147
212
  const ENGINE_NAME = 'openscript';
@@ -149,7 +214,18 @@ const ENGINE_NAME = 'openscript';
149
214
  /** The parts a run hands over, each already in the shape the record holds. */
150
215
  export interface RecordParts {
151
216
  readonly program: CompiledProgram;
217
+ /**
218
+ * The script's own text, so the record can become a conformance case.
219
+ *
220
+ * Optional because a caller that has only a compiled program cannot invent
221
+ * it, and a run is still worth recording without it. `caseFilesFrom` is the
222
+ * one thing that then cannot be served, and it says so rather than writing a
223
+ * case with a hole in it.
224
+ */
225
+ readonly sourceText?: string;
152
226
  readonly settings: BacktestSettings;
227
+ /** The instrument record the engine was handed, `host-interface.md` 4.1. */
228
+ readonly instrument: Instrument;
153
229
  readonly bars: readonly RecordedBar[];
154
230
  /**
155
231
  * Whether the bars travel in the record or are pointed at.
@@ -180,11 +256,13 @@ export function recordOf(parts: RecordParts): RunRecord {
180
256
  return {
181
257
  recordVersion: RECORD_VERSION,
182
258
  engine: { name: ENGINE_NAME, version: VERSION },
183
- languageVersion: String(parts.program.openscript.language),
259
+ languageVersion: canonicalNumber(parts.program.openscript.language),
184
260
  program: parts.program,
185
261
  programHash: programHash(parts.program),
186
262
  source: parts.program.source,
263
+ sourceText: textFor(parts),
187
264
  settings: parts.settings,
265
+ instrument: parts.instrument,
188
266
  bars: barsIn(parts.bars, parts.form),
189
267
  frames: parts.frames,
190
268
  fills: parts.fills,
@@ -194,6 +272,26 @@ export function recordOf(parts: RecordParts): RunRecord {
194
272
  };
195
273
  }
196
274
 
275
+ /**
276
+ * The source text, checked against the hash the program already carries.
277
+ *
278
+ * A check rather than a promise, and it costs one hash of a few kilobytes. The
279
+ * failure it exists for is quiet: a caller that passes the text of a different
280
+ * revision than the one it compiled produces a case whose script does not make
281
+ * its own expected output, and the engine being tested gets the blame for a
282
+ * disagreement that was in the case all along.
283
+ */
284
+ function textFor(parts: RecordParts): string | null {
285
+ const text = parts.sourceText;
286
+ if (text === undefined) return null;
287
+ if (sourceHash(text) !== parts.program.source.hash) {
288
+ throw new Error(
289
+ 'openscript: the source text does not hash to the source hash the program carries',
290
+ );
291
+ }
292
+ return text;
293
+ }
294
+
197
295
  /**
198
296
  * The bars, held or pointed at, and the same hash either way.
199
297
  *
@@ -283,7 +381,38 @@ export function recordFromJson(text: string): RunRecord | null {
283
381
  const parsed: unknown = JSON.parse(text);
284
382
  if (parsed === null || typeof parsed !== 'object') return null;
285
383
  const record = parsed as RunRecord;
286
- return record.recordVersion === RECORD_VERSION ? record : null;
384
+ const version = record.recordVersion;
385
+ if (typeof version !== 'number' || version < 1 || version > RECORD_VERSION) return null;
386
+ // An earlier revision is readable and a later one is not, and the asymmetry
387
+ // is the point. A later revision may mean something by a field this one
388
+ // thinks it knows, which is how a record silently becomes a different run. An
389
+ // earlier one only ever has fewer: every channel it carries means here what
390
+ // it meant there, and the ones added since are absent rather than wrong. So a
391
+ // run stored months ago still reads, which is the whole of what it was stored
392
+ // for, and a channel it never carried reads as absent.
393
+ if (version === RECORD_VERSION) return record;
394
+ return {
395
+ ...record,
396
+ sourceText: version >= ADDED_IN.sourceText ? record.sourceText : null,
397
+ instrument: version >= ADDED_IN.instrument ? record.instrument : null,
398
+ frames: version >= ADDED_IN.frameTime ? record.frames : timeless(record.frames),
399
+ };
400
+ }
401
+
402
+ /**
403
+ * The frames of a record written before a frame carried an instant.
404
+ *
405
+ * The absence is written on every frame rather than on the record, because the
406
+ * channel is a field of a row: a reader that left the field undefined would
407
+ * hand the case projection an undefined where the column's absent spelling
408
+ * belongs, and the file would read `undefined` back to the next engine. The
409
+ * frames are left as they are when they are not an array, because this is a
410
+ * parse and not a validation and a document this engine did not write is the
411
+ * caller's to trust or not.
412
+ */
413
+ function timeless(frames: readonly RecordedFrame[]): readonly RecordedFrame[] {
414
+ if (!Array.isArray(frames)) return frames;
415
+ return frames.map((frame) => ({ ...frame, time: null }));
287
416
  }
288
417
 
289
418
  /**
@@ -88,7 +88,17 @@ export function replay(record: RunRecord, bars: readonly RecordedBar[] | null =
88
88
  export function rerun(record: RunRecord, bars: readonly RecordedBar[] | null = null): BacktestResult {
89
89
  const held = barsOf(record, bars);
90
90
  if (!held.ok) return { ok: false, diagnostic: held.diagnostic };
91
- return backtest(record.program, held.bars, record.settings, { form: record.bars.form });
91
+ // Everything the run depended on, and that includes the two channels that
92
+ // arrived after the sentence above was written. The instrument record is what
93
+ // the engine read at load, so a rerun handed the contract alone runs under
94
+ // different session facts and does not reproduce the bytes; the text is what
95
+ // makes the rerun's record harvestable, and dropping it would turn a record
96
+ // that could become a case into one that cannot, by being rerun.
97
+ return backtest(record.program, held.bars, record.settings, {
98
+ form: record.bars.form,
99
+ ...(record.instrument === null ? {} : { instrument: record.instrument }),
100
+ ...(record.sourceText === null ? {} : { sourceText: record.sourceText }),
101
+ });
92
102
  }
93
103
 
94
104
  type BarsResult =