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,67 @@
1
+ """What a case expects, read out of its own files into one mapping by channel.
2
+
3
+ ``conformance.md`` section 4 splits the expectation across two files for a reason
4
+ it gives: ``expected.csv`` is one value per bar and one column per asserted
5
+ channel, and ``expected.json`` is everything that is not, each channel an ordered
6
+ list of flat objects. The comparison wants one shape, so both are read here into
7
+ the shape the answer already has, and a channel the case asserts that neither
8
+ file holds is a malformed case rather than a failure of the engine.
9
+
10
+ **A cell is read as the channel's own type, and the case file does not carry
11
+ one.** ``expected.csv`` has a header of column names and no types, so the four
12
+ letters ``true`` in a string column and the bool are the same eight bytes on
13
+ disk. The type comes from the compiled program's channel table
14
+ (``compiled-program.md`` section 2.7), which the run has already resolved, and it
15
+ is passed in here rather than guessed from the shape of the text: a guess would
16
+ read a string channel that emitted ``true`` as a bool and report a difference
17
+ that is in the reader rather than in either engine.
18
+ """
19
+
20
+ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
21
+
22
+ from .reading import Case
23
+ from .spellings import Malformed, read_cell
24
+
25
+ #: Section 4: the channel whose expectation is columnar, and the file it is in.
26
+ COLUMNAR = "values"
27
+
28
+
29
+ def values_expected(
30
+ columns: Sequence[str], rows: Sequence[Sequence[str]], types: Mapping[str, str]
31
+ ) -> list:
32
+ """``expected.csv`` as one flat object per bar, in the answer's own shape."""
33
+ out = []
34
+ for at, row in enumerate(rows):
35
+ held: Dict[str, Any] = {}
36
+ for which, name in enumerate(columns):
37
+ kind = types.get(name)
38
+ if kind is None:
39
+ raise Malformed(
40
+ f"expected.csv names the column {name} and this program has no channel for it"
41
+ )
42
+ held[name] = read_cell(row[which], kind, f"expected.csv line {at + 2}, {name}")
43
+ out.append(held)
44
+ return out
45
+
46
+
47
+ def expected_channels(
48
+ case: Case, types: Mapping[str, str]
49
+ ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
50
+ """Every asserted channel as the case holds it, or the sentence refusing it."""
51
+ found: Dict[str, Any] = {}
52
+ for channel in case.asserts:
53
+ if channel == COLUMNAR:
54
+ if not case.expected_columns:
55
+ return None, "the case asserts values and holds no expected.csv (section 4)"
56
+ found[channel] = values_expected(case.expected_columns, case.expected_rows, types)
57
+ continue
58
+ if case.expected_json is None or channel not in case.expected_json:
59
+ return None, (
60
+ f"expected.json holds no {channel}, which case.json asserts. A case asserts the "
61
+ "channels it names, and one it names with nothing to compare against is malformed"
62
+ )
63
+ held = case.expected_json[channel]
64
+ if not isinstance(held, list):
65
+ return None, f"expected.json's {channel} is not an ordered list (section 4)"
66
+ found[channel] = held
67
+ return found, None
@@ -0,0 +1,127 @@
1
+ """The two namespaces whose answer is the host's or the ledger's, not an argument's.
2
+
3
+ ``stdlib.md`` 3.4 is the ``chart`` namespace, 17.4 is the ``pos`` namespace and
4
+ 12.4 is the session's one derived entry. None of the three is in ``library/``,
5
+ and that is the library's decision rather than an omission: every function there
6
+ computes its answer from its arguments and its own region, and these read the
7
+ instrument record the host stated, the fills this strategy settled and the
8
+ boundary ``sessions.py`` derives. All of those arrive at this seam and nowhere
9
+ else, so the manifest rows for them are here, beside the join that can answer
10
+ them.
11
+
12
+ **A fact nobody stated is absent**, which is ``host-interface.md`` 4.1's column
13
+ for every entry but one: ``chart.symbol`` is the empty string, because 3.4 says
14
+ so. A default anywhere else would make "the smallest increment is five paise"
15
+ and "nobody said" the same reading.
16
+
17
+ **The two derived facts are not here.** ``chart.intervalMinutes`` and
18
+ ``chart.isIntraday`` are computed from the interval string by section 15.2's
19
+ canonical form, which this engine does not read yet, so a program calling one is
20
+ refused at load by name (OS6004) rather than answered from a guess at what the
21
+ string meant. The planned entries of both tables are absent for the same reason:
22
+ a name the language does not serve yet is a refusal naming it.
23
+ """
24
+
25
+ from typing import Any, Dict, Optional, Protocol, Tuple
26
+
27
+ from ..values import ABSENT
28
+ from .sessions import SESSION_FACTS
29
+
30
+ #: Section 3.4 against ``host-interface.md`` 4.1: the call, and the fact of the
31
+ #: instrument record it reads. Every one is arity 0, holds no state and has no
32
+ #: effect, which is what the manifest check holds a program's own table to.
33
+ CHART_FACTS: Dict[str, str] = {
34
+ "chart.symbol": "symbol",
35
+ "chart.exchange": "exchange",
36
+ "chart.interval": "interval",
37
+ "chart.timezone": "timezone",
38
+ "chart.tickSize": "tickSize",
39
+ "chart.lotSize": "lotSize",
40
+ "chart.pointValue": "pointValue",
41
+ "chart.currency": "currency",
42
+ "chart.instrumentType": "instrumentType",
43
+ "chart.hasVolume": "hasVolume",
44
+ "chart.hasOpenInterest": "hasOpenInterest",
45
+ }
46
+
47
+ #: The one entry of that table whose absent case is a value rather than absence.
48
+ EMPTY_WHEN_UNSTATED: Tuple[str, ...] = ("chart.symbol",)
49
+
50
+ #: Section 3.4: the clock, whose value a case fixes so that a script using it is
51
+ #: still reproducible. It is the one entry of the namespace that is not a fact of
52
+ #: the instrument.
53
+ CHART_CLOCK = "chart.now"
54
+
55
+ #: Section 17.4: the entries of the ``pos`` namespace that are not planned. Each
56
+ #: is folded from this strategy's own fills and reflects fills rather than
57
+ #: intentions, which is the position book's whole job.
58
+ POSITION_FACTS: Tuple[str, ...] = (
59
+ "pos.size",
60
+ "pos.isLong",
61
+ "pos.isShort",
62
+ "pos.isFlat",
63
+ "pos.avgPrice",
64
+ )
65
+
66
+
67
+ class Book(Protocol):
68
+ """What a position fact is read from, and the whole of what it is asked.
69
+
70
+ The ledger answers it. Two methods rather than the ledger itself, so that
71
+ this module states what a reading of the ``pos`` namespace reaches: the
72
+ settled position and nothing about the orders that made it.
73
+ """
74
+
75
+ def size(self) -> float:
76
+ ...
77
+
78
+ def avg_price(self) -> Optional[float]:
79
+ ...
80
+
81
+
82
+ def chart_value(name: str, instrument: Any, now: Any) -> Any:
83
+ """One ``chart`` entry, from the record the host stated and the fixed clock."""
84
+ if name == CHART_CLOCK:
85
+ return now
86
+ held = instrument.get(CHART_FACTS[name], ABSENT)
87
+ if held is ABSENT and name in EMPTY_WHEN_UNSTATED:
88
+ return ""
89
+ return held
90
+
91
+
92
+ def position_value(name: str, book: Book) -> Any:
93
+ """One ``pos`` entry, from the ledger's own position book.
94
+
95
+ ``pos.size`` is ``0`` while flat rather than absent, because zero is the true
96
+ size and a script adding it to something should get the right answer.
97
+ ``pos.avgPrice`` is absent while flat rather than zero, because zero is a
98
+ price and a script comparing against it would take a branch that looks
99
+ correct. Section 17.4 states both, and the difference is the reason the two
100
+ are not one reading with a default.
101
+ """
102
+ if name == "pos.avgPrice":
103
+ return book.avg_price()
104
+ size = book.size()
105
+ if name == "pos.size":
106
+ return size
107
+ if name == "pos.isLong":
108
+ return size > 0
109
+ if name == "pos.isShort":
110
+ return size < 0
111
+ return size == 0
112
+
113
+
114
+ #: Every name this module answers. The manifest rows built from it are all arity
115
+ #: 0, hold no state and have no effect, which is what section 2.5's check holds a
116
+ #: program's own library table to. The session's own entry is beside them because
117
+ #: it is read the same way and answered from the bar rather than from a record.
118
+ FACT_NAMES: Tuple[str, ...] = (*CHART_FACTS, CHART_CLOCK, *POSITION_FACTS, *SESSION_FACTS)
119
+
120
+
121
+ def fact_value(name: str, instrument: Any, now: Any, book: Book, bar: Any) -> Any:
122
+ """Whichever of the three namespaces this name belongs to, read once."""
123
+ if name in POSITION_FACTS:
124
+ return position_value(name, book)
125
+ if name in SESSION_FACTS:
126
+ return bar
127
+ return chart_value(name, instrument, now)
@@ -0,0 +1,257 @@
1
+ """The comparison of ``conformance.md`` section 6, in this language.
2
+
3
+ The page prints the function and every step of it, and this is that function with
4
+ nothing added. Each step is written out below beside the wrong implementation it
5
+ refuses, because the whole value of the suite rests on this being the same rule
6
+ in both engines: a comparison that was a little more forgiving in one of them
7
+ would report agreement that is not there, which is worse than reporting none.
8
+
9
+ 1. Two absent values match, asked before anything numeric is.
10
+ 2. One absent and one present fail, whatever the tolerance. A number is not
11
+ nearly absent, and warmup length is a specified property, so a value one bar
12
+ early is a defect however small it is.
13
+ 3. A result that is not finite is its own outcome. ``language.md`` section 5.1
14
+ says an infinity and a not-a-number never appear as values, so producing one
15
+ is a defect rather than a near miss, and burying it among numeric failures
16
+ would hide the one failure that is never a rounding difference.
17
+ 4. Signed zero is normalised. Nothing in the language can observe the sign of a
18
+ zero, so comparing raw bits would fail a case over a difference no script can
19
+ see.
20
+ 5. Equality is over the eight bytes, not over a rendering, so a formatting
21
+ decision can never make two different values look equal.
22
+ 6. With both bounds zero, anything past step 5 fails.
23
+ 7. The bound is ``max(abs, rel * |e|)`` and never a sum: exactly one bound is in
24
+ force at any magnitude, and a failure can name which of the two it broke.
25
+
26
+ **A declared tolerance is held to the caps section 6 prints.** A case past either
27
+ is not a conformance case at all, and it is reported ``error`` rather than run
28
+ under a bound the page refuses.
29
+ """
30
+
31
+ import struct
32
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
33
+
34
+ from .page import TOLERANCE_CAP_ABS, TOLERANCE_CAP_REL
35
+ from .spellings import written
36
+
37
+ #: Section 4: what a diagnostic is compared on, and nothing else. The message
38
+ #: text and the suggested fix are deliberately not compared, because improving
39
+ #: the wording of an error is something this project wants to keep doing.
40
+ DIAGNOSTIC_FIELDS: Tuple[str, ...] = ("code", "line", "column", "severity")
41
+
42
+ #: Section 6: exact, which is what a comparison is until a case declares otherwise.
43
+ EXACT = (0.0, 0.0)
44
+
45
+ _PASS: Dict[str, Any] = {"outcome": "pass"}
46
+
47
+
48
+ def _bits(value: float) -> bytes:
49
+ """The eight bytes of a binary64, so equality is over bits and not over text."""
50
+ return struct.pack(">d", value)
51
+
52
+
53
+ def _is_number(value: Any) -> bool:
54
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
55
+
56
+
57
+ def compare_numbers(actual: Any, expected: Any, abs_bound: float, rel_bound: float) -> Dict[str, Any]:
58
+ """Steps 1 to 8, in the page's order, over one asserted numeric value."""
59
+ if expected is None and actual is None:
60
+ return _PASS
61
+ if expected is None or actual is None:
62
+ return {"outcome": "fail", "bound": "absence"}
63
+ if not _is_number(actual) or not _finite(actual):
64
+ return {"outcome": "nonFinite"}
65
+ if not _is_number(expected) or not _finite(expected):
66
+ return {"outcome": "nonFinite"}
67
+ left = 0.0 if actual == 0 else float(actual)
68
+ right = 0.0 if expected == 0 else float(expected)
69
+ if _bits(left) == _bits(right):
70
+ return _PASS
71
+ difference = abs(left - right)
72
+ if abs_bound == 0 and rel_bound == 0:
73
+ return {"outcome": "fail", "bound": "exact", "difference": difference}
74
+ relative = rel_bound * abs(right)
75
+ if difference <= max(abs_bound, relative):
76
+ return _PASS
77
+ return {
78
+ "outcome": "fail",
79
+ "bound": "abs" if abs_bound >= relative else "rel",
80
+ "difference": difference,
81
+ }
82
+
83
+
84
+ def _finite(value: Any) -> bool:
85
+ return value == value and value not in (float("inf"), float("-inf"))
86
+
87
+
88
+ def _kind_of(value: Any) -> str:
89
+ if value is None:
90
+ return "absent"
91
+ if isinstance(value, bool):
92
+ return "bool"
93
+ if _is_number(value):
94
+ return "number"
95
+ if isinstance(value, str):
96
+ return "string"
97
+ return "list" if isinstance(value, list) else "object"
98
+
99
+
100
+ def compare_values(actual: Any, expected: Any, tolerance: Tuple[float, float]) -> Dict[str, Any]:
101
+ """One value of any kind, under section 6's table.
102
+
103
+ Absence is asked first for every kind, because the table says it is never
104
+ subject to a tolerance and that is as true of a string as of a number. Two
105
+ values of different kinds fail as ``kind``: a bool where a number was
106
+ expected is not a near miss.
107
+ """
108
+ if expected is None or actual is None:
109
+ return compare_numbers(actual, expected, tolerance[0], tolerance[1])
110
+ kind = _kind_of(expected)
111
+ if kind != _kind_of(actual):
112
+ return {"outcome": "fail", "bound": "kind"}
113
+ if kind == "number":
114
+ return compare_numbers(actual, expected, tolerance[0], tolerance[1])
115
+ if kind in ("bool", "string"):
116
+ return _PASS if actual == expected else {"outcome": "fail", "bound": "exact"}
117
+ return _PASS if _stable(actual) == _stable(expected) else {"outcome": "fail", "bound": "exact"}
118
+
119
+
120
+ def _stable(value: Any) -> str:
121
+ """A nested value as one piece of text, keys sorted, so two spellings are one."""
122
+ if isinstance(value, list):
123
+ return "[" + ",".join(_stable(one) for one in value) + "]"
124
+ if isinstance(value, dict):
125
+ members = (f"{key}:{_stable(value[key])}" for key in sorted(value))
126
+ return "{" + ",".join(members) + "}"
127
+ return written(value)
128
+
129
+
130
+ def _fields_of(channel: str, expected: Dict[str, Any]) -> Sequence[str]:
131
+ """Section 4: four fields for a diagnostic, else the expected element's own.
132
+
133
+ "Compared on the fields the case names and no others" is what lets a case
134
+ assert the part of a ledger row it is about without freezing every other
135
+ field of it.
136
+ """
137
+ if channel == "diagnostics":
138
+ return DIAGNOSTIC_FIELDS
139
+ return sorted(expected)
140
+
141
+
142
+ def _difference(
143
+ result: Dict[str, Any],
144
+ channel: str,
145
+ index: Optional[int],
146
+ column: Optional[str],
147
+ actual: Any,
148
+ expected: Any,
149
+ ) -> Dict[str, Any]:
150
+ found = {
151
+ "outcome": result["outcome"],
152
+ "channel": channel,
153
+ "index": index,
154
+ "column": column,
155
+ "expected": written(expected),
156
+ "actual": written(actual),
157
+ }
158
+ if "bound" in result:
159
+ found["bound"] = result["bound"]
160
+ if "difference" in result:
161
+ found["difference"] = written(result["difference"])
162
+ return found
163
+
164
+
165
+ def compare_channel(
166
+ channel: str, actual: Any, expected: Any, tolerance: Tuple[float, float]
167
+ ) -> Optional[Dict[str, Any]]:
168
+ """One channel: an ordered list, length first, then element by element.
169
+
170
+ Nothing when the channel matches, else the first difference with its index,
171
+ its column and both values as a report writes them. A length mismatch is
172
+ reported before any element is compared, which is section 6's table, and at
173
+ the first index one list has and the other lacks.
174
+ """
175
+ if not isinstance(expected, list) or not isinstance(actual, list):
176
+ return {
177
+ "outcome": "fail",
178
+ "channel": channel,
179
+ "index": None,
180
+ "column": None,
181
+ "bound": "kind",
182
+ "expected": _kind_of(expected),
183
+ "actual": _kind_of(actual),
184
+ }
185
+ if len(actual) != len(expected):
186
+ return {
187
+ "outcome": "fail",
188
+ "channel": channel,
189
+ "index": min(len(actual), len(expected)),
190
+ "column": None,
191
+ "bound": "length",
192
+ "expected": str(len(expected)),
193
+ "actual": str(len(actual)),
194
+ }
195
+ for index, want in enumerate(expected):
196
+ got = actual[index]
197
+ if not isinstance(want, dict):
198
+ result = compare_values(got, want, tolerance)
199
+ if result["outcome"] != "pass":
200
+ return _difference(result, channel, index, None, got, want)
201
+ continue
202
+ for column in _fields_of(channel, want):
203
+ wanted = want.get(column)
204
+ found = got.get(column) if isinstance(got, dict) else None
205
+ result = compare_values(found, wanted, tolerance)
206
+ if result["outcome"] != "pass":
207
+ return _difference(result, channel, index, column, found, wanted)
208
+ return None
209
+
210
+
211
+ def compare_channels(
212
+ asserted: Sequence[str],
213
+ actual: Dict[str, Any],
214
+ expected: Dict[str, Any],
215
+ tolerance: Tuple[float, float],
216
+ ) -> Dict[str, Any]:
217
+ """Every asserted channel, in the case's own order, to one outcome."""
218
+ for channel in asserted:
219
+ found = compare_channel(channel, actual.get(channel), expected.get(channel), tolerance)
220
+ if found is not None:
221
+ return found
222
+ return dict(_PASS)
223
+
224
+
225
+ def tolerance_from(declared: Any) -> Tuple[Optional[Tuple[float, float]], Optional[str]]:
226
+ """The case's tolerance, held to section 6, or the sentence refusing it.
227
+
228
+ Absent means exact. A non-zero bound needs a stated reason, which is the rule
229
+ that stops a tolerance from being the thing an engine author widens until the
230
+ suite goes green. A bound past the cap is not a conformance case at all.
231
+ """
232
+ if declared is None:
233
+ return EXACT, None
234
+ if not isinstance(declared, dict):
235
+ return None, "tolerance is not an object"
236
+ bounds: List[float] = []
237
+ for name in ("abs", "rel"):
238
+ value = declared.get(name, 0)
239
+ if value is None:
240
+ value = 0
241
+ if not _is_number(value) or not _finite(value):
242
+ return None, f"tolerance.{name} is not a finite number"
243
+ if value < 0:
244
+ return None, f"tolerance.{name} is below zero"
245
+ bounds.append(float(value))
246
+ abs_bound, rel_bound = bounds[0], bounds[1]
247
+ reason = declared.get("reason")
248
+ stated = isinstance(reason, str) and reason.strip() != ""
249
+ if (abs_bound != 0 or rel_bound != 0) and not stated:
250
+ return None, "a non-zero tolerance is declared with no reason (section 6)"
251
+ if abs_bound > TOLERANCE_CAP_ABS or rel_bound > TOLERANCE_CAP_REL:
252
+ return None, (
253
+ f"a tolerance of abs {written(abs_bound)} and rel {written(rel_bound)} is looser than "
254
+ f"the cap of abs {written(TOLERANCE_CAP_ABS)} and rel {written(TOLERANCE_CAP_REL)}, so "
255
+ "this is not a conformance case (section 6)"
256
+ )
257
+ return (abs_bound, rel_bound), None
@@ -0,0 +1,187 @@
1
+ """The nine order calls as this engine's manifest holds them, and the desk behind them.
2
+
3
+ ``stdlib.md`` 17.2 and 17.3 are the calls, ``host-interface.md`` 7.2 and 7.4 are
4
+ the frames and the boundary they fold at, and ``strategy/`` is all of the
5
+ behaviour. What is here is the join: the manifest rows a program's own library
6
+ table is checked against, and the object that carries a ledger, its intents and
7
+ its fills through one case.
8
+
9
+ **An order call is an effect and is not performed when it executes.**
10
+ ``compiled-program.md`` 5.4 has a function with an effect leave a record and push
11
+ absence, and step 9 applies the record only on a bar the engine decided. So the
12
+ machine hands this desk the calls a decided bar left behind, in the order the bar
13
+ made them, and a condition that was true halfway through a moving bar and false
14
+ when it closed places no order at all.
15
+
16
+ **A frame is delivered after a bar and folded before the next one.**
17
+ ``conformance.md`` section 3 says ``afterBar`` is "the index of the bar after
18
+ whose execution the frame is delivered, so the fold happens at a bar boundary
19
+ before the next execution". Read as it is written, a frame naming the last bar is
20
+ delivered and never folded, because there is no next execution to fold before.
21
+ That is a hole in the page rather than a decision to make here, so such a case is
22
+ named ``unsupported`` by the caller rather than answered under one reading of
23
+ two.
24
+
25
+ **A refusal takes back the whole bar.** ``Ledger.discard`` is what that costs, and
26
+ the reason is in its own docstring: every call of a bar is mapped before any of
27
+ them is routed, so a refusal after one has appended a row would otherwise leave a
28
+ row no destination was ever handed.
29
+ """
30
+
31
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
32
+
33
+ from ..contracts import LibraryEntry
34
+ from ..diagnostics import Diagnostic
35
+ from ..strategy import (
36
+ Fills,
37
+ Identity,
38
+ IntentBar,
39
+ Intents,
40
+ Ledger,
41
+ LedgerOptions,
42
+ LedgerRow,
43
+ OrderFrame,
44
+ )
45
+ from ..strategy.calls import PARAMETERS
46
+ from .reading import Frame
47
+
48
+ #: Section 2.5's row for each of the nine, by name and argument count. The count
49
+ #: is one more than the signature's, which is ``compiled-program.md`` 4.10's
50
+ #: extra argument: the names of the arguments the script wrote. None of them
51
+ #: holds state, and every one of them has the ``order`` effect, which is what
52
+ #: makes the machine hold the call until step 9 rather than calling into here.
53
+ ORDER_ENTRIES: Dict[Tuple[str, int], LibraryEntry] = {
54
+ (name, len(parameters) + 1): LibraryEntry(name, len(parameters) + 1, False, "order")
55
+ for name, parameters in PARAMETERS.items()
56
+ }
57
+
58
+
59
+ class Desk:
60
+ """One strategy's ledger, its intents and its fills, driven over one case.
61
+
62
+ The caller owns the bar loop, because the bars are the case's and the
63
+ execution is the machine's. What is owned here is the order of the three
64
+ things that happen between two executions, and that order is
65
+ ``host-interface.md`` 7.4's rather than this class's choice.
66
+ """
67
+
68
+ def __init__(self, frames: Sequence[Frame] = ()) -> None:
69
+ self.ledger = Ledger()
70
+ self.intents = Intents()
71
+ self.fills = Fills()
72
+ #: The destination's own reference per intent, recorded at delivery, so
73
+ #: that a fill carries the reference the frame that made it carried.
74
+ self._refs: Dict[int, str] = {}
75
+ self._after: Dict[int, List[Frame]] = {}
76
+ for frame in frames:
77
+ self._after.setdefault(frame.after_bar, []).append(frame)
78
+ self._waiting: Tuple[Frame, ...] = ()
79
+
80
+ def begin(self, options: LedgerOptions) -> None:
81
+ """The declaration's own settings, before bar 0 and after the load.
82
+
83
+ A ledger exists before the program is loaded, because the manifest has to
84
+ answer whether this engine holds ``pos.size`` before anything has read a
85
+ declaration, and the declaration's settings can only be read once the
86
+ inputs behind them are resolved, which is what a load does. Nothing has
87
+ placed an order in between: the first bar is what places one.
88
+ """
89
+ self.ledger.options = options
90
+
91
+ # -- the boundary between two bars --------------------------------------
92
+
93
+ def fold(self, index: int, time: Optional[float]) -> None:
94
+ """Deliver what the destination sent after the last bar, then fold it.
95
+
96
+ Delivered first and folded second, in one call, because the two are one
97
+ boundary: a driver that folded before delivering would hold every frame
98
+ for a bar longer, and a script would read a position one bar late.
99
+ """
100
+ for frame in self._waiting:
101
+ named = self.intents.at_ordinal(frame.intent)
102
+ if named is not None and frame.order_ref != "":
103
+ self._refs[named.intent_id] = frame.order_ref
104
+ self.ledger.deliver(
105
+ OrderFrame(
106
+ # An ordinal naming no intent this run placed is how a case
107
+ # hands an engine a frame about an order its ledger does not
108
+ # hold. Step 1 of the fold refuses it, which is the ledger's
109
+ # to do and not this driver's, so an id no run can mint goes
110
+ # over rather than the frame being dropped here.
111
+ intent_id=-1 if named is None else named.intent_id,
112
+ status=frame.status,
113
+ filled_qty=frame.filled_qty,
114
+ avg_fill_price=frame.avg_fill_price,
115
+ order_ref=frame.order_ref,
116
+ text=frame.text,
117
+ # When the destination answered. Left off, every frame
118
+ # carried the instant of the bar that placed the order, so a
119
+ # case whose destination answered later was answered one way
120
+ # here and another by the first engine, at `updatedAt` and
121
+ # at nothing else.
122
+ time=frame.time,
123
+ )
124
+ )
125
+ self._waiting = ()
126
+ self.fills.record(self.ledger.settle(), self.intents, index, time, self._refs)
127
+
128
+ def deliver_after(self, index: int) -> None:
129
+ """The frames this case says the destination sent after bar ``index``."""
130
+ self._waiting = tuple(self._after.get(index, ()))
131
+
132
+ # -- what a decided bar sent --------------------------------------------
133
+
134
+ def apply(self, effects: Iterable[Any], bar: IntentBar) -> Optional[Diagnostic]:
135
+ """Step 9's order calls, in the order the bar made them, or the refusal.
136
+
137
+ Every effect a program of this engine's can leave is one of the nine,
138
+ because they are the only entries in its manifest with an effect at all,
139
+ so the name goes to the ledger rather than being sorted here.
140
+ """
141
+ appended = len(self.ledger.rows())
142
+ for effect in effects:
143
+ placed = self.ledger.place(effect.name, effect.arguments, bar, effect.position)
144
+ if placed.refusal is not None:
145
+ self.ledger.discard(appended)
146
+ return placed.refusal
147
+ self.intents.record(placed.intents)
148
+ return None
149
+
150
+ # -- what a script and a report read ------------------------------------
151
+
152
+ def size(self) -> float:
153
+ return self.ledger.size()
154
+
155
+ def avg_price(self) -> Optional[float]:
156
+ return self.ledger.avg_price()
157
+
158
+ def rows(self) -> Sequence[LedgerRow]:
159
+ return self.ledger.rows()
160
+
161
+
162
+ def unfoldable(frames: Sequence[Frame], bars: int) -> Tuple[Frame, ...]:
163
+ """The frames of a case that no boundary folds, which is the hole above.
164
+
165
+ A frame named after the last bar is delivered and never folded, so a case
166
+ carrying one is answered ``unsupported`` naming it rather than run to a
167
+ ledger that is missing whatever it said.
168
+ """
169
+ return tuple(frame for frame in frames if frame.after_bar >= bars - 1)
170
+
171
+
172
+ def options_for(declared: Dict[str, Any], instrument: Dict[str, Any]) -> LedgerOptions:
173
+ """What the declaration and the instrument record fix before bar 0.
174
+
175
+ ``declared`` is the strategy block of the compiled program's meta, with every
176
+ input reference already resolved: a script may declare its own quantity from
177
+ an input, and a ledger handed the reference rather than the value would size
178
+ every order from a shape.
179
+ """
180
+ return LedgerOptions(
181
+ instrument=Identity(instrument.get("symbol"), instrument.get("exchange")),
182
+ product=declared["product"],
183
+ qty_type=declared["qtyType"],
184
+ declared_qty=declared["qty"],
185
+ tick_size=instrument.get("tickSize"),
186
+ pyramiding=declared["pyramiding"],
187
+ )