openscript 0.4.0__py3-none-any.whl

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 (97) hide show
  1. openscript/__init__.py +40 -0
  2. openscript/__main__.py +62 -0
  3. openscript/accounting/__init__.py +74 -0
  4. openscript/accounting/analysis.py +174 -0
  5. openscript/accounting/charges.py +397 -0
  6. openscript/accounting/equity.py +234 -0
  7. openscript/accounting/report.py +82 -0
  8. openscript/accounting/shapes.py +74 -0
  9. openscript/accounting/statistics.py +300 -0
  10. openscript/accounting/trades.py +294 -0
  11. openscript/adapter/__init__.py +32 -0
  12. openscript/adapter/answers.py +215 -0
  13. openscript/adapter/channels.py +137 -0
  14. openscript/adapter/expectations.py +67 -0
  15. openscript/adapter/facts.py +127 -0
  16. openscript/adapter/matching.py +257 -0
  17. openscript/adapter/ordering.py +187 -0
  18. openscript/adapter/page.py +130 -0
  19. openscript/adapter/reading.py +357 -0
  20. openscript/adapter/reporting.py +244 -0
  21. openscript/adapter/running.py +449 -0
  22. openscript/adapter/serving.py +229 -0
  23. openscript/adapter/sessions.py +168 -0
  24. openscript/adapter/spellings.py +184 -0
  25. openscript/bars.py +157 -0
  26. openscript/budget.py +342 -0
  27. openscript/canonical.py +192 -0
  28. openscript/civil.py +196 -0
  29. openscript/contracts.py +165 -0
  30. openscript/dates.py +302 -0
  31. openscript/diagnostics.py +104 -0
  32. openscript/hours.py +165 -0
  33. openscript/inputs.py +239 -0
  34. openscript/intervals.py +60 -0
  35. openscript/library/__init__.py +76 -0
  36. openscript/library/arithmetic.py +128 -0
  37. openscript/library/averages.py +133 -0
  38. openscript/library/bars.py +60 -0
  39. openscript/library/bookkeeping.py +166 -0
  40. openscript/library/code_points.py +85 -0
  41. openscript/library/colour.py +202 -0
  42. openscript/library/composites.py +208 -0
  43. openscript/library/counting.py +218 -0
  44. openscript/library/deviation.py +155 -0
  45. openscript/library/elementary.py +206 -0
  46. openscript/library/extremes.py +122 -0
  47. openscript/library/flows.py +220 -0
  48. openscript/library/momentum.py +203 -0
  49. openscript/library/number_text.py +223 -0
  50. openscript/library/prices.py +36 -0
  51. openscript/library/ranges.py +105 -0
  52. openscript/library/rounding.py +123 -0
  53. openscript/library/series.py +213 -0
  54. openscript/library/stateful.py +442 -0
  55. openscript/library/stateless.py +261 -0
  56. openscript/library/strength.py +180 -0
  57. openscript/library/strings.py +228 -0
  58. openscript/library/trend.py +260 -0
  59. openscript/library/values.py +91 -0
  60. openscript/logbook.py +119 -0
  61. openscript/machine.py +499 -0
  62. openscript/memory.py +204 -0
  63. openscript/opcodes.py +166 -0
  64. openscript/program.py +146 -0
  65. openscript/run.py +368 -0
  66. openscript/strategy/__init__.py +78 -0
  67. openscript/strategy/calls.py +201 -0
  68. openscript/strategy/closable.py +182 -0
  69. openscript/strategy/fills.py +131 -0
  70. openscript/strategy/holdings.py +277 -0
  71. openscript/strategy/intents.py +162 -0
  72. openscript/strategy/ledger.py +270 -0
  73. openscript/strategy/placing.py +206 -0
  74. openscript/strategy/positions.py +124 -0
  75. openscript/strategy/refusals.py +293 -0
  76. openscript/strategy/rows.py +219 -0
  77. openscript/strategy/sizing.py +229 -0
  78. openscript/strategy/statuses.py +65 -0
  79. openscript/surface/__init__.py +115 -0
  80. openscript/surface/bands.py +103 -0
  81. openscript/surface/levels.py +44 -0
  82. openscript/surface/marks.py +52 -0
  83. openscript/surface/paints.py +58 -0
  84. openscript/surface/plots.py +44 -0
  85. openscript/surface/published.py +119 -0
  86. openscript/values.py +210 -0
  87. openscript/verify.py +301 -0
  88. openscript/verify_code.py +290 -0
  89. openscript/verify_requests.py +271 -0
  90. openscript/verify_shape.py +162 -0
  91. openscript/verify_tables.py +256 -0
  92. openscript/version.py +39 -0
  93. openscript/zones.py +118 -0
  94. openscript-0.4.0.dist-info/METADATA +82 -0
  95. openscript-0.4.0.dist-info/RECORD +97 -0
  96. openscript-0.4.0.dist-info/WHEEL +5 -0
  97. openscript-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,449 @@
1
+ """One case, run on this engine, and the channels that come out of it.
2
+
3
+ This is the half of the adapter that is about the engine rather than about the
4
+ command line. It takes a case directory already read and the compiled program
5
+ the invocation was handed, loads the program, executes every bar of ``bars.csv``
6
+ and projects what it computed into the channels the case asserts, in the
7
+ encoding ``conformance.md`` section 4 gives each of them. It compares nothing.
8
+
9
+ **What this engine cannot do is named rather than guessed at, and the engine is
10
+ what names it.** ``compiled-program.md`` section 9.4 has a load refuse a program
11
+ whose capability tag the engine does not serve (OS6006), whose library entry its
12
+ manifest does not hold (OS6004), and whose format or language version it does not
13
+ implement (OS6016, OS6017). Each of those refusals carries the thing it refused,
14
+ by name, so the ``unsupported`` outcome section 9 asks for is read out of the
15
+ engine's own answer instead of being decided by a list kept here. A list kept
16
+ here would say what somebody believed this engine cannot do; the refusal says
17
+ what it actually refused.
18
+
19
+ **Every other refusal is a diagnostic and not an absence of support.** A
20
+ malformed program, a stored setting the engine will not run with, a budget
21
+ overrun, a division the script wrote: those are things this engine did, they
22
+ carry a code and a position, and they go into the ``diagnostics`` channel where a
23
+ case can assert them.
24
+
25
+ **The bars are confirmed and are not live.** A case is a run over a dataset, so
26
+ every execution is a new, confirmed, non-realtime bar with one update, and the
27
+ count supplied is the whole file: that is what makes ``bar.isLast`` true on the
28
+ last row and false everywhere else. An engine that only backtests is handed
29
+ ``isRealtime`` false throughout, which is why no alert is raised here, and
30
+ ``run.py`` reaches the same answer from the other side.
31
+
32
+ **A strategy case is the same loop with a desk beside it.** The frames are the
33
+ case's, delivered after the bar ``frames.csv`` names and folded before the next
34
+ execution; the orders are the calls step 9 applied, in the order the bar made
35
+ them; and the trades and the summary are folded after the last bar from the
36
+ fills, under the three facts ``backtest.json`` states. Nothing in that sentence
37
+ is this module's decision: ``ordering`` holds the boundary, ``reporting`` holds
38
+ what the report was folded under, and ``accounting`` holds the arithmetic. What
39
+ is here is the order the three are asked in.
40
+ """
41
+
42
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
43
+
44
+ from ..accounting import report_of
45
+ from ..contracts import Bar as EngineBar, BarState
46
+ from ..inputs import utc_time
47
+ from ..run import load_text
48
+ from ..strategy import IntentBar
49
+ from ..values import ABSENT
50
+ from ..verify import capabilities
51
+ from .channels import orders_channel, performance_channel, trade_row
52
+ from .ordering import ORDER_ENTRIES, Desk, options_for, unfoldable
53
+ from .reading import Bar, Case
54
+ from .reporting import contract_for, marks_for, schedule_for, settings_problem
55
+ from .serving import Serving, is_reference
56
+ from .sessions import (
57
+ READABLE_ZONE,
58
+ SESSION_FACTS,
59
+ SESSION_FIRST,
60
+ first_bars,
61
+ session_from,
62
+ )
63
+ from .spellings import Malformed, as_reported
64
+
65
+ #: The codes a load raises when the program needs something this engine does not
66
+ #: have, each with the field naming what it was.
67
+ _UNSUPPORTED_CODES = {
68
+ "OS6006": ("tag", "the capability {value}"),
69
+ "OS6004": ("name", "the library function {value}"),
70
+ "OS6016": ("found", "the compiled format {value}"),
71
+ "OS6017": ("found", "language version {value}"),
72
+ }
73
+
74
+ #: Section 4: the channels this engine answers today. Everything else a case may
75
+ #: assert is named ``unsupported`` on the case rather than answered emptily,
76
+ #: because an empty channel compares equal to an empty expectation and would be a
77
+ #: pass nobody earned.
78
+ ANSWERED = ("diagnostics", "values", "orders", "trades", "performance")
79
+
80
+ #: ``compiled-program.md`` 2.2's tag for a program that places orders, and the
81
+ #: word the meta uses for a program that is one. This engine serves the tag
82
+ #: because it has a ledger: what it cannot serve is refused by name at load.
83
+ ORDERS = "orders"
84
+ STRATEGY = "strategy"
85
+
86
+ #: What this engine reads out of the declaration: the five the ledger sizes an
87
+ #: order from, and the three the money charges a fill under, plus the capital
88
+ #: every figure in the report is a fraction of.
89
+ DECLARED: Tuple[str, ...] = (
90
+ "qty",
91
+ "qtyType",
92
+ "product",
93
+ "pyramiding",
94
+ "capital",
95
+ "commission",
96
+ "commissionType",
97
+ "slippage",
98
+ )
99
+
100
+
101
+ class Answer:
102
+ """What one case run produced: channels, and what could not be answered.
103
+
104
+ ``column_types`` is the declared type of the channel behind each column of
105
+ ``expected.csv`` that this program resolved, which is what lets a cell of
106
+ that file be read as the value it is rather than as the shape of its text.
107
+ """
108
+
109
+ def __init__(self) -> None:
110
+ self.channels: Dict[str, Any] = {}
111
+ self.unsupported: List[str] = []
112
+ self.column_types: Dict[str, str] = {}
113
+
114
+ def cannot(self, feature: str) -> None:
115
+ if feature not in self.unsupported:
116
+ self.unsupported.append(feature)
117
+
118
+
119
+ def _diagnostic_row(code: str, line: int, column: int, bar: Optional[int]) -> Dict[str, Any]:
120
+ """A diagnostic in the columns section 4 compares one on, and the bar it was on."""
121
+ return {"code": code, "line": line, "column": column, "severity": "error", "barIndex": bar}
122
+
123
+
124
+ def _refusal_row(refused: Any) -> Dict[str, Any]:
125
+ """A setting refusal, as a run refused before its first bar records it.
126
+
127
+ A setting the run cannot be carried out under is refused while nothing has
128
+ been computed, so it carries no position in the source: the defect is in what
129
+ the host stated rather than in a line of the script. The message's own values
130
+ are not in the row, because section 4 compares a diagnostic on its code, its
131
+ line, its column and its severity and deliberately not on its wording.
132
+ """
133
+ return _diagnostic_row(refused.code, refused.line, refused.column, None)
134
+
135
+
136
+ def _unsupported_from(diagnostic: Any) -> Optional[str]:
137
+ """The feature a load refusal names, or nothing when it is a real diagnostic."""
138
+ held = _UNSUPPORTED_CODES.get(diagnostic.code)
139
+ if held is None:
140
+ return None
141
+ field, sentence = held
142
+ value = diagnostic.values.get(field)
143
+ return sentence.format(value=value)
144
+
145
+
146
+ def _engine_bar(bar: Bar) -> EngineBar:
147
+ """One row of the file as the engine's own bar. An absent field stays absent."""
148
+ return EngineBar(
149
+ time=float(bar.time),
150
+ open=bar.open,
151
+ high=bar.high,
152
+ low=bar.low,
153
+ close=bar.close,
154
+ volume=bar.volume,
155
+ oi=ABSENT,
156
+ )
157
+
158
+
159
+ def _plot_channels(program: Dict[str, Any]) -> Dict[str, int]:
160
+ """Every plot's legend title and its stable key, against its channel.
161
+
162
+ ``compiled-program.md`` section 11 maps ``outputs.plots[]`` and their
163
+ channels to one column per plot, which is what a column of ``expected.csv``
164
+ is. The title is what a legend shows and what an expected file names; the key
165
+ is accepted beside it because it is the plot's stable identity and a case may
166
+ reasonably have been written against either.
167
+ """
168
+ found: Dict[str, int] = {}
169
+ for plot in program["outputs"]["plots"]:
170
+ for name in (plot["title"], plot["key"]):
171
+ if isinstance(name, str) and name not in found:
172
+ found[name] = plot["channel"]
173
+ return found
174
+
175
+
176
+ def _values_channel(
177
+ columns: Sequence[str], program: Dict[str, Any], rows: Sequence[List[Any]], answer: Answer
178
+ ) -> List[Dict[str, Any]]:
179
+ """One object per bar, holding the asserted columns and nothing else.
180
+
181
+ A column names a plot, and a plot names a channel. A column this program has
182
+ no plot for is not answered as absence, because absence is a value a case can
183
+ assert and a column nobody can resolve is a case this engine cannot run.
184
+ """
185
+ channels = _plot_channels(program)
186
+ resolved: List[Tuple[str, int]] = []
187
+ for name in columns:
188
+ if name not in channels:
189
+ answer.cannot(
190
+ f"the column {name}: this program declares no plot with that title or key, so "
191
+ "there is no channel for it to be read from"
192
+ )
193
+ continue
194
+ resolved.append((name, channels[name]))
195
+ answer.column_types[name] = program["channels"][channels[name]]["type"]
196
+ out: List[Dict[str, Any]] = []
197
+ for row in rows:
198
+ held: Dict[str, Any] = {}
199
+ for name, channel in resolved:
200
+ value = row[channel] if channel < len(row) else ABSENT
201
+ held[name] = None if is_reference(value) else as_reported(value)
202
+ out.append(held)
203
+ return out
204
+
205
+
206
+ def _unreadable_zone(program: Dict[str, Any], instrument: Dict[str, Any]) -> Optional[str]:
207
+ """What this program reads in a calendar this engine cannot read, or nothing.
208
+
209
+ ``stdlib.md`` section 12.1 reads a calendar field in the chart's timezone,
210
+ which the host states; this engine carries a reader for one zone and a host
211
+ with any other supplies its own. A case in another zone whose script takes a
212
+ written time, or asks where a session begins, is therefore a case this engine
213
+ would answer under the wrong calendar, and it says so instead.
214
+ """
215
+ if instrument.get("timezone") == READABLE_ZONE:
216
+ return None
217
+ if any(one["kind"] == "time" for one in program["inputs"]):
218
+ return "a time input"
219
+ if any(one["name"] in SESSION_FACTS for one in program["lib"]["functions"]):
220
+ return "a session boundary"
221
+ return None
222
+
223
+
224
+ def run_case(case: Case, program_text: str) -> Answer:
225
+ """Load the program, run every bar, and project the asserted channels."""
226
+ answer = Answer()
227
+ unanswered = [channel for channel in case.asserts if channel not in ANSWERED]
228
+ if unanswered:
229
+ answer.cannot(
230
+ f"the {', '.join(unanswered)} channel{'' if len(unanswered) == 1 else 's'} "
231
+ "of section 2: this engine answers "
232
+ f"{' and '.join(ANSWERED)}, and every other channel belongs to a stage that is not "
233
+ "wired into it yet"
234
+ )
235
+ if case.ticks:
236
+ answer.cannot(
237
+ "ticks.csv (section 3): the machine re-executes a bar and rolls its state back, and "
238
+ "how a tick row becomes the newest bar's four prices is not written down anywhere, so "
239
+ "a replay here would be this adapter's invention"
240
+ )
241
+ for name in case.secondary:
242
+ answer.cannot(f"{name} (section 3): this engine is handed one series and reads no other")
243
+ if case.frames is not None and case.bars is not None:
244
+ beyond = unfoldable(case.frames, len(case.bars))
245
+ if beyond:
246
+ answer.cannot(
247
+ f"frames.csv: {len(beyond)} frame{'' if len(beyond) == 1 else 's'} delivered after "
248
+ f"bar {beyond[0].after_bar} of {len(case.bars)} (section 3): a frame is delivered "
249
+ "after a bar and folded before the next execution, and the last bar has none, so "
250
+ "what becomes of it is not written down anywhere"
251
+ )
252
+ if "expectedExitCode" in case.declared:
253
+ answer.cannot(
254
+ "case.json's expectedExitCode: section 2 names the field and fixes no shape for it, so "
255
+ "a reading here would be one no other engine has to share"
256
+ )
257
+
258
+ if answer.unsupported:
259
+ return answer
260
+
261
+ desk = Desk(case.frames or ())
262
+ serving = Serving(desk)
263
+ loaded = load_text(
264
+ program_text,
265
+ dict(case.settings),
266
+ serving,
267
+ capabilities=capabilities(ORDERS),
268
+ read_time=utc_time,
269
+ )
270
+ if loaded.diagnostic is not None:
271
+ feature = _unsupported_from(loaded.diagnostic)
272
+ if feature is not None:
273
+ answer.cannot(feature)
274
+ return answer
275
+ found = loaded.diagnostic
276
+ answer.channels = _with_empty(
277
+ case, {"diagnostics": [_diagnostic_row(found.code, found.line, found.column, None)]}
278
+ )
279
+ return answer
280
+
281
+ run = loaded.run
282
+ raw = run.program.raw
283
+ unreadable = _unreadable_zone(raw, case.instrument)
284
+ if unreadable is not None:
285
+ answer.cannot(
286
+ f"{unreadable} under the timezone {case.instrument.get('timezone')}: this engine reads "
287
+ f"a calendar as {READABLE_ZONE} and a host with another zone supplies its own reader"
288
+ )
289
+ if answer.unsupported:
290
+ return answer
291
+
292
+ if case.bars is None:
293
+ needed = [one for one in case.asserts if one != "diagnostics"]
294
+ if needed:
295
+ raise Malformed(
296
+ f"the case asserts {', '.join(needed)} and holds no bars.csv, which section 2 "
297
+ "requires of an engine case"
298
+ )
299
+ answer.channels = {"diagnostics": []}
300
+ return answer
301
+
302
+ trading = raw["meta"]["kind"] == STRATEGY
303
+ if not trading and any((one["name"], one["arity"]) in ORDER_ENTRIES for one in raw["lib"]["functions"]):
304
+ # The ledger is sized by the declaration a strategy carries and a study
305
+ # has none, so an order sent from one would be an order sized by this
306
+ # adapter's defaults. The compiler does not emit such a program; a file
307
+ # that arrives as one is refused rather than run under numbers nobody
308
+ # wrote.
309
+ raise Malformed(
310
+ "the program declares itself a study and calls an order function, which is a program "
311
+ "with orders to place and no declaration to size them from"
312
+ )
313
+ declared = _declaration(run) if trading else {}
314
+ schedule = None
315
+ if trading:
316
+ # Every one of the three arrives before a bar runs, and each from the file
317
+ # section 3 puts it in: a run under a digit count nobody stated is a run
318
+ # that agrees with the engine next door by coincidence.
319
+ desk.begin(options_for(declared, case.instrument))
320
+ contract = contract_for(case)
321
+ schedule = schedule_for(case, declared)
322
+ refused = settings_problem(schedule, declared, contract)
323
+ if refused is not None:
324
+ answer.channels = _with_empty(case, {"diagnostics": [_refusal_row(refused)]})
325
+ return answer
326
+
327
+ rows, diagnostics = _every_bar(run, serving, desk, case)
328
+ answered: Dict[str, Any] = {"diagnostics": diagnostics}
329
+ if "values" in case.asserts:
330
+ if not case.expected_columns:
331
+ raise Malformed(
332
+ "the case asserts values and holds no expected.csv, which section 4 says is where "
333
+ "one column per asserted channel and one row per bar are written"
334
+ )
335
+ answered["values"] = _values_channel(case.expected_columns, raw, rows, answer)
336
+ if "orders" in case.asserts:
337
+ answered["orders"] = orders_channel(desk.rows(), desk.intents)
338
+ if "trades" in case.asserts or "performance" in case.asserts:
339
+ report = report_of(
340
+ desk.fills.settled(),
341
+ marks_for(case, case.bars),
342
+ schedule,
343
+ contract_for(case),
344
+ declared.get("capital", 0.0),
345
+ )
346
+ answered["trades"] = [trade_row(one) for one in report.trades]
347
+ answered["performance"] = performance_channel(report.summary)
348
+ answer.channels = _empty_but(case, answered)
349
+ return answer
350
+
351
+
352
+ def _empty_but(case: Case, answered: Dict[str, Any]) -> Dict[str, Any]:
353
+ """The asserted channels this engine answers, and nothing it does not."""
354
+ return {channel: answered[channel] for channel in case.asserts if channel in answered}
355
+
356
+
357
+ def _with_empty(case: Case, answered: Dict[str, Any]) -> Dict[str, Any]:
358
+ """The asserted channels a run that did not happen has nothing for, as empty lists.
359
+
360
+ A refusal before the first bar leaves every channel with nothing in it, and
361
+ nothing is an empty list rather than a channel left out: a case comparing an
362
+ absent channel and one comparing an empty one are different reports, and the
363
+ second is the one that says how far the run got.
364
+ """
365
+ return {channel: answered.get(channel, []) for channel in case.asserts}
366
+
367
+
368
+ def _declaration(run: Any) -> Dict[str, Any]:
369
+ """The strategy declaration, with every input reference behind it resolved.
370
+
371
+ Read field by field through the run's own reader rather than off the program,
372
+ because ``language.md`` 13 lets a declaration state a quantity, a commission
373
+ or a capital from an input, and a ledger handed the reference rather than the
374
+ value would size every order from a shape.
375
+
376
+ The eight names below are what this engine reads out of the declaration, and
377
+ a program of kind strategy that states none of them is refused here rather
378
+ than part way through a bar: a ledger sizing an order from a field that was
379
+ not there would be an order nobody wrote.
380
+ """
381
+ block = run.program.raw["meta"].get(STRATEGY)
382
+ if not isinstance(block, dict):
383
+ raise Malformed(
384
+ "the program declares itself a strategy and its meta holds no strategy declaration, "
385
+ "which compiled-program.md 2.3 says every one of them carries"
386
+ )
387
+ missing = [name for name in DECLARED if name not in block]
388
+ if missing:
389
+ raise Malformed(
390
+ f"the strategy declaration states no {', '.join(missing)}, which this engine reads to "
391
+ "size an order and to charge a fill"
392
+ )
393
+ return {name: run.declaration(("meta", STRATEGY, name)) for name in block}
394
+
395
+
396
+ def _every_bar(
397
+ run: Any, serving: Serving, desk: Desk, case: Case
398
+ ) -> Tuple[List[List[Any]], List[Dict[str, Any]]]:
399
+ """Every bar of the file, in order, stopping at the first that fails.
400
+
401
+ A study stops at the bar that failed and reports the diagnostic: the columns
402
+ of the bars before it stand, and there are no columns after it. That is the
403
+ engine's own behaviour and not a decision here, and it is what makes a length
404
+ difference in the values channel the first thing a reader of a failed case
405
+ sees.
406
+
407
+ **The fold is before the execution and the orders are after it**, which is
408
+ ``host-interface.md`` 7.4: a driver that folded after the bar would let a
409
+ script react within the bar its own order was sent in, and one that folded
410
+ during it would give two executions of a moving bar two different positions
411
+ to read. An order call refused stops the run the same way a failed bar does,
412
+ and takes back every row that bar had appended.
413
+ """
414
+ bars = case.bars or []
415
+ supplied = len(bars)
416
+ when = case.declared.get("now", ABSENT)
417
+ opens = first_bars([bar.time for bar in bars], session_from(case.instrument))
418
+ rows: List[List[Any]] = []
419
+ for index, bar in enumerate(bars):
420
+ desk.fold(index, float(bar.time))
421
+ previous = bars[index - 1].close if index > 0 else ABSENT
422
+ serving.at_bar(
423
+ {
424
+ "high": bar.high,
425
+ "low": bar.low,
426
+ "close": bar.close,
427
+ "previousClose": previous,
428
+ "volume": bar.volume,
429
+ SESSION_FIRST: opens[index],
430
+ },
431
+ index == 0,
432
+ )
433
+ result = run.execute_bar(
434
+ index,
435
+ _engine_bar(bar),
436
+ BarState(is_new=True, is_confirmed=True, is_realtime=False, updates=1.0),
437
+ supplied=supplied,
438
+ instrument=case.instrument,
439
+ now=when,
440
+ )
441
+ if result.diagnostic is not None:
442
+ found = result.diagnostic
443
+ return rows, [_diagnostic_row(found.code, found.line, found.column, index)]
444
+ sent = desk.apply(result.applied, IntentBar(index=index, time=float(bar.time)))
445
+ if sent is not None:
446
+ return rows, [_diagnostic_row(sent.code, sent.line, sent.column, index)]
447
+ rows.append(list(result.columns))
448
+ desk.deliver_after(index)
449
+ return rows, []
@@ -0,0 +1,229 @@
1
+ """The seam between the library's entries and the machine's ``Library`` protocol.
2
+
3
+ ``contracts.py`` says what the machine asks of a library: a manifest to disagree
4
+ with at load, a sentence describing what this engine holds instead, and a way to
5
+ call one. ``library/`` answers a different shape, and two of them: a stateless
6
+ table whose calls take a context and the arguments, and a stateful one whose
7
+ calls take the call site's region as well. Nothing in the package joins them, so
8
+ this adapter carries the join, and it carries it here rather than inside the
9
+ answer so that the day a module in the package does it, one import changes and
10
+ nothing else does.
11
+
12
+ **Four tables and not one.** Beside the two halves of the library are the two
13
+ namespaces that are not in it: the ``chart`` and ``pos`` facts of ``facts.py``,
14
+ whose answer is the host's record and the strategy's own fills, and the nine
15
+ order calls of ``ordering.py``, which carry an effect and are therefore never
16
+ called through here at all. A name in none of the four is a name this engine's
17
+ manifest does not hold, and a program calling it is refused at load (OS6004)
18
+ naming the function and what this engine holds instead.
19
+
20
+ **Two things the machine cannot pass through, and how they are served.**
21
+
22
+ ``CallContext`` carries a bar index, the instrument facts and the fixed value of
23
+ the clock. It does not carry the bar. ``stdlib.md`` section 6's ``trueRange``
24
+ reads the bar's high, its low and the previous bar's close, and section 20.5
25
+ grants the oldest bar the library's one exception to absence propagation, so a
26
+ context of three fields cannot answer it. The caller driving the bars knows all
27
+ three, so it states them here before each execution, and a call that arrives
28
+ without them is answered with absence rather than with a value read from
29
+ somewhere else.
30
+
31
+ ``roundToTick`` asks the host for the instrument's tick, which ``CallContext``
32
+ does carry, so it is read from there and from nowhere else.
33
+
34
+ **The heap is not a table.** An array on this machine is the object itself
35
+ (``values.ArrayValue``), so making one and reading one are the two lines below
36
+ rather than an allocation and an index.
37
+
38
+ **A colour has two spellings in this package, and this seam is where they meet.**
39
+ The machine builds one shape from the constant pool and the library returns
40
+ another, so a colour a script computes is not the type the machine's own tag
41
+ function knows. That is a defect in the package rather than in either half, and
42
+ it is written up in the stage's report; nothing here papers over it, because a
43
+ seam that converted quietly would hide the one place a test can see it.
44
+ """
45
+
46
+ from typing import Any, Dict, List, Optional, Sequence
47
+
48
+ from ..contracts import CallContext, LibraryEntry
49
+ from ..library import BUILDS_A_STRING, MEASURED, stateful_table, table
50
+ from ..library.stateless import Entry
51
+ from ..values import ABSENT, ArrayValue, Reference, tag
52
+ from .facts import FACT_NAMES, POSITION_FACTS, Book, fact_value
53
+ from .ordering import ORDER_ENTRIES
54
+ from .sessions import SESSION_FIRST
55
+
56
+
57
+ class Serving:
58
+ """The library and the two namespaces that are not in it, as a ``Library``.
59
+
60
+ One instance per run. ``at_bar`` is called by whatever drives the bars,
61
+ before each execution, with the three facts a bar-reading call needs and no
62
+ context carries.
63
+
64
+ ``book`` is the strategy's position book, which is the ledger. A run without
65
+ one serves no ``pos`` entry and no order call, so a program that places an
66
+ order is refused at load naming the function (OS6004) rather than running as
67
+ a study that quietly trades nothing.
68
+ """
69
+
70
+ def __init__(self, book: Optional[Book] = None) -> None:
71
+ self._entries = table()
72
+ self._stateful = stateful_table()
73
+ self._book = book
74
+ self._bar: Dict[str, Any] = {}
75
+ self._first = False
76
+ self._context: Optional[CallContext] = None
77
+
78
+ # -- what the caller states before a bar --------------------------------
79
+
80
+ def at_bar(self, facts: Dict[str, Any], first: bool) -> None:
81
+ """The bar facts a library call reads, for the execution about to happen.
82
+
83
+ Six of them, and each is read by name: ``high``, ``low``, ``close``,
84
+ ``previousClose``, ``volume`` and ``isSessionFirst``. The names are the
85
+ library's, asked for through ``bar``, and a fact the caller does not state
86
+ is absent rather than a value read from somewhere else: a study that
87
+ answered absence for every bar would be a study with a silently empty
88
+ line through it, and this is the one place that could happen quietly.
89
+ """
90
+ self._bar = dict(facts)
91
+ self._first = first
92
+
93
+ # -- the manifest, which is what a load is held to ----------------------
94
+
95
+ def _facts(self) -> Sequence[str]:
96
+ """The fact names this run can answer, which the ``pos`` half depends on."""
97
+ if self._book is not None:
98
+ return FACT_NAMES
99
+ return tuple(name for name in FACT_NAMES if name not in POSITION_FACTS)
100
+
101
+ def entry(self, name: str, arity: int) -> Optional[LibraryEntry]:
102
+ """One manifest row, from whichever of the four tables holds the name.
103
+
104
+ The order they are asked in decides nothing: a name is in one of them,
105
+ because the two halves of the library are keyed apart by their own module
106
+ and neither namespace is a function the library holds.
107
+ """
108
+ held = self._entries.get((name, arity)) or self._stateful.get((name, arity))
109
+ if held is not None:
110
+ return LibraryEntry(held.name, held.arity, held.state, held.effect)
111
+ if arity == 0 and name in self._facts():
112
+ return LibraryEntry(name, 0, False, "none")
113
+ return ORDER_ENTRIES.get((name, arity))
114
+
115
+ def _arities(self, name: str) -> Sequence[int]:
116
+ """Every argument count this engine holds the name under."""
117
+ found = {arity for (held, arity) in self._entries if held == name}
118
+ found |= {arity for (held, arity) in self._stateful if held == name}
119
+ found |= {arity for (held, arity) in ORDER_ENTRIES if held == name}
120
+ if name in self._facts():
121
+ found.add(0)
122
+ return sorted(found)
123
+
124
+ def length_of(self, name: str, arguments: Sequence[Any]) -> Optional[int]:
125
+ """How long the string this call will build is, before it is built.
126
+
127
+ The library says which of its calls can answer that and how, and the
128
+ machine spends the ceiling: a string no engine could hold is reported
129
+ rather than allocated. Absence for every other name, which is the honest
130
+ answer and not a refusal to look: a padded or a replaced string's length
131
+ is not known until it is built, and building it costs what it costs.
132
+
133
+ Neither namespace is here. A ``chart`` fact is the instrument record the
134
+ host stated and a ``pos`` fact is a number, so neither is a string this
135
+ bar grew.
136
+ """
137
+ measure = MEASURED.get((name, len(arguments)))
138
+ return None if measure is None else measure(*arguments)
139
+
140
+ def builds_a_string(self, name: str) -> bool:
141
+ """Whether this call makes the string it answers, rather than passing one on.
142
+
143
+ The ceiling is on what a script grows. A call that hands back a string
144
+ it was given, or one the host stated, grew nothing, and holding it to the
145
+ ceiling would refuse a host its own long instrument name on a script that
146
+ only read it.
147
+ """
148
+ return name in BUILDS_A_STRING
149
+
150
+ def describe(self, name: str) -> str:
151
+ """What this engine's manifest holds for a name, in OS6004's own words."""
152
+ arities = self._arities(name)
153
+ if not arities:
154
+ return f"no function called {name}"
155
+ spelled = " or ".join(str(one) for one in arities)
156
+ return f"{name} with {spelled} arguments"
157
+
158
+ # -- the call -----------------------------------------------------------
159
+
160
+ def call(
161
+ self,
162
+ name: str,
163
+ arguments: Sequence[Any],
164
+ state: Optional[Dict[str, Any]],
165
+ context: CallContext,
166
+ ) -> Any:
167
+ """One ``CALL_LIB``, dispatched by the name and the argument count.
168
+
169
+ ``state`` is the call site's own region and is passed to the half of the
170
+ library that keeps one and to nothing else: a region handed to a
171
+ stateless call would be a region the load-time check should have refused,
172
+ and a stateful call reaching this engine without one is the same
173
+ disagreement from the other side, so it answers absence rather than
174
+ making a region of its own that no rollback would ever restore.
175
+
176
+ An order call never arrives here. Its manifest row carries an effect, so
177
+ the machine holds it until step 9 and hands it to the ledger, which is
178
+ ``compiled-program.md`` 5.4 and the whole reason a strategy is
179
+ reproducible on a moving bar.
180
+ """
181
+ self._context = context
182
+ held: Optional[Entry] = self._entries.get((name, len(arguments)))
183
+ if held is not None:
184
+ return held.call(self, list(arguments))
185
+ keeping = self._stateful.get((name, len(arguments)))
186
+ if keeping is not None:
187
+ return ABSENT if state is None else keeping.call(self, list(arguments), state)
188
+ if len(arguments) == 0 and name in self._facts():
189
+ return fact_value(
190
+ name, self._instrument(), self._now(), self._book, self._bar.get(SESSION_FIRST, ABSENT)
191
+ )
192
+ return ABSENT
193
+
194
+ # -- the six members a stateless call may ask for -----------------------
195
+
196
+ def bar(self, fact: str) -> Any:
197
+ return self._bar.get(fact, ABSENT)
198
+
199
+ def host(self, fact: str) -> Any:
200
+ if self._context is None:
201
+ return ABSENT
202
+ return self._context.instrument.get(fact, ABSENT)
203
+
204
+ def first_bar(self) -> bool:
205
+ return self._first
206
+
207
+ def _instrument(self) -> Any:
208
+ """The record the host stated, which is what a ``chart`` fact is read from."""
209
+ return {} if self._context is None else self._context.instrument
210
+
211
+ def _now(self) -> Any:
212
+ """The clock's fixed value, which a case states and no engine reads."""
213
+ return ABSENT if self._context is None else self._context.now
214
+
215
+ def kind_of(self, reference: Any) -> str:
216
+ return tag(reference)
217
+
218
+ def items_of(self, reference: Any) -> Optional[List[Any]]:
219
+ if isinstance(reference, ArrayValue):
220
+ return list(reference.elements)
221
+ return None
222
+
223
+ def make_array(self, items: List[Any]) -> Any:
224
+ return ArrayValue(list(items))
225
+
226
+
227
+ def is_reference(value: Any) -> bool:
228
+ """Whether a value is one of the heap's, which a channel never carries out."""
229
+ return isinstance(value, Reference)