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,294 @@
1
+ """What a trade is, which is the definition every statistic is counted over.
2
+
3
+ **A trade is one position reference, from the fill that first takes it away from
4
+ zero to the fill that returns it to zero.** Nothing else is invented, because the
5
+ engine already mints a reference per position, no order crosses zero, and every
6
+ fill names the reference it settled however late it arrives.
7
+
8
+ Everything awkward falls out of that rather than needing a rule of its own. A
9
+ pyramided entry is more entry fills on one trade. A partial close is an exit fill
10
+ that does not close the trade. A flip is two references and therefore two trades,
11
+ which is what a reversing order already sends. A reference still holding
12
+ something at the last bar is an open trade: it is in the list with ``is_open``
13
+ true, it is counted in equity, and it is counted in no win rate.
14
+
15
+ **What the fold reads, and what it refuses to work out for itself.** A fill
16
+ carries the signed size of its reference either side of the settlement, so the
17
+ fold reads what the position book did rather than recomputing it from quantities
18
+ and sides. That is the whole of how a partial close, a pyramided entry and a
19
+ reversal tell themselves apart. It also settles the one case the definition does
20
+ not cover on its face: a destination that fills more than the order asked takes a
21
+ reference through zero rather than to it, so the fill closes the trade that was
22
+ held and opens a second one on the same reference, at its own price and on its
23
+ own bar.
24
+
25
+ **Reducing a position does not move its average**, which is the position book's
26
+ rule read here rather than a second one: a reducing fill adds to the exits and
27
+ touches neither the entry quantity nor the entry cost.
28
+
29
+ **A charge lands whole on one trade and is never split.** It was rounded once for
30
+ the fill that incurred it, and splitting it would round it again and put a residue
31
+ somewhere. So it is attributed to the trade the fill closed where it closed one,
32
+ and to the trade it opened otherwise, which makes the charges of the trades add
33
+ up to the charges of the fills exactly rather than nearly.
34
+
35
+ **Gross profit is over the units that have left.** For a closed trade that is
36
+ every unit it entered. For one still open it is what its exits have realised so
37
+ far, which is a figure that is true rather than a zero standing in for money the
38
+ run has already made.
39
+ """
40
+
41
+ from dataclasses import dataclass
42
+ from typing import List, Optional, Sequence, Tuple
43
+
44
+ from .shapes import BarMark, Contract, RecordedFill
45
+
46
+
47
+ def _sign(value: float) -> int:
48
+ """The sign of a size, which is the whole of how a move is read.
49
+
50
+ Written here rather than imported from the ledger, because this package
51
+ imports no engine: a stored record has to be reportable again with no engine
52
+ present, which is what makes a run record a conformance case rather than a
53
+ souvenir.
54
+ """
55
+ if value > 0:
56
+ return 1
57
+ if value < 0:
58
+ return -1
59
+ return 0
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Trade:
64
+ """One round trip on one position reference."""
65
+
66
+ #: 1-based, in the order the trade opened.
67
+ index: int
68
+ position_ref: int
69
+ side: str
70
+ opened_on_bar: int
71
+ opened_at: Optional[float]
72
+ closed_on_bar: Optional[int]
73
+ closed_at: Optional[float]
74
+ bars_held: Optional[int]
75
+ #: Total units entered.
76
+ units: float
77
+ #: Quantity weighted over the entry fills.
78
+ entry_price: float
79
+ #: Quantity weighted over the exit fills.
80
+ exit_price: Optional[float]
81
+ entries: int
82
+ exits: int
83
+ gross_profit: float
84
+ charges: float
85
+ net_profit: float
86
+ #: Excursion at bar closes while open. Favourable is zero or better, adverse
87
+ #: zero or worse, and both are zero for a trade no bar closed on.
88
+ max_favourable: float
89
+ max_adverse: float
90
+ is_open: bool
91
+
92
+
93
+ @dataclass
94
+ class _Building:
95
+ """A trade while it is still being folded: the totals a round trip is made of."""
96
+
97
+ index: int
98
+ position_ref: int
99
+ side: str
100
+ opened_on_bar: int
101
+ opened_at: Optional[float]
102
+ closed_on_bar: Optional[int] = None
103
+ closed_at: Optional[float] = None
104
+ entry_units: float = 0.0
105
+ entry_cost: float = 0.0
106
+ exit_units: float = 0.0
107
+ exit_cost: float = 0.0
108
+ entries: int = 0
109
+ exits: int = 0
110
+ charges: float = 0.0
111
+ #: Signed, and what the reference holds for this trade right now.
112
+ size: float = 0.0
113
+ max_favourable: float = 0.0
114
+ max_adverse: float = 0.0
115
+
116
+
117
+ def closed_by(before: float, after: float) -> float:
118
+ """How much of a move from one size to another closed what was held.
119
+
120
+ A move to the other side of zero closed all of it, which is the case a
121
+ destination that overfilled produces and the one this has to get right.
122
+ """
123
+ if before == 0:
124
+ return 0.0
125
+ same = _sign(after) == _sign(before)
126
+ return max(0.0, abs(before) - abs(after)) if same else abs(before)
127
+
128
+
129
+ def opened_by(before: float, after: float) -> float:
130
+ """And how much of it opened something, which is the rest of the same move."""
131
+ if after == 0:
132
+ return 0.0
133
+ if before == 0 or _sign(after) != _sign(before):
134
+ return abs(after)
135
+ return max(0.0, abs(after) - abs(before))
136
+
137
+
138
+ def _average_of(cost: float, units: float) -> float:
139
+ return 0.0 if units == 0 else cost / units
140
+
141
+
142
+ def _begin(fill: RecordedFill, units: float, index: int) -> _Building:
143
+ return _Building(
144
+ index=index,
145
+ position_ref=fill.position_ref,
146
+ side="long" if fill.ref_size_after > 0 else "short",
147
+ opened_on_bar=fill.bar_index,
148
+ opened_at=fill.bar_time,
149
+ entry_units=units,
150
+ entry_cost=units * fill.price,
151
+ entries=1,
152
+ size=fill.ref_size_after,
153
+ )
154
+
155
+
156
+ def _fold(
157
+ fill: RecordedFill, charge: float, built: List[_Building], live: List[_Building]
158
+ ) -> None:
159
+ """One fill against the trades its reference holds."""
160
+ closing = closed_by(fill.ref_size_before, fill.ref_size_after)
161
+ opening = opened_by(fill.ref_size_before, fill.ref_size_after)
162
+ at = -1
163
+ for index, trade in enumerate(live):
164
+ if trade.position_ref == fill.position_ref:
165
+ at = index
166
+ break
167
+ held = None if at < 0 else live[at]
168
+ paid = False
169
+
170
+ if held is not None and closing > 0:
171
+ held.exit_units += closing
172
+ held.exit_cost += closing * fill.price
173
+ held.exits += 1
174
+ held.charges += charge
175
+ paid = True
176
+ # What the reference left this trade holding: nothing at all when the
177
+ # fill carried it through zero, since the other side of zero is the next
178
+ # trade.
179
+ held.size = 0.0 if opening > 0 else fill.ref_size_after
180
+ if held.size == 0:
181
+ held.closed_on_bar = fill.bar_index
182
+ held.closed_at = fill.bar_time
183
+ del live[at]
184
+
185
+ if opening > 0:
186
+ # A fill that closed something opens a trade of its own rather than
187
+ # adding to the one it just finished.
188
+ adding = None if closing > 0 else held
189
+ if adding is None:
190
+ fresh = _begin(fill, opening, len(built) + 1)
191
+ built.append(fresh)
192
+ live.append(fresh)
193
+ if not paid:
194
+ fresh.charges += charge
195
+ else:
196
+ adding.entry_units += opening
197
+ adding.entry_cost += opening * fill.price
198
+ adding.entries += 1
199
+ adding.size = fill.ref_size_after
200
+ if not paid:
201
+ adding.charges += charge
202
+ return
203
+
204
+ # A fill that moved nothing still cost something, and it cost it on account
205
+ # of the trade its reference is holding.
206
+ if not paid and held is not None:
207
+ held.charges += charge
208
+
209
+
210
+ def _mark_to(live: Sequence[_Building], close: float, point_value: float) -> None:
211
+ """Every open trade against one bar's close."""
212
+ for trade in live:
213
+ entry = _average_of(trade.entry_cost, trade.entry_units)
214
+ excursion = (close - entry) * trade.size * point_value
215
+ if excursion > trade.max_favourable:
216
+ trade.max_favourable = excursion
217
+ if excursion < trade.max_adverse:
218
+ trade.max_adverse = excursion
219
+
220
+
221
+ def _finish(trade: _Building, contract: Contract) -> Trade:
222
+ entry_price = _average_of(trade.entry_cost, trade.entry_units)
223
+ exit_price = None if trade.exit_units == 0 else trade.exit_cost / trade.exit_units
224
+ way = 1 if trade.side == "long" else -1
225
+ gross = (
226
+ 0.0
227
+ if exit_price is None
228
+ else (exit_price - entry_price) * trade.exit_units * contract.point_value * way
229
+ )
230
+ return Trade(
231
+ index=trade.index,
232
+ position_ref=trade.position_ref,
233
+ side=trade.side,
234
+ opened_on_bar=trade.opened_on_bar,
235
+ opened_at=trade.opened_at,
236
+ closed_on_bar=trade.closed_on_bar,
237
+ closed_at=trade.closed_at,
238
+ bars_held=None if trade.closed_on_bar is None else trade.closed_on_bar - trade.opened_on_bar,
239
+ units=trade.entry_units,
240
+ entry_price=entry_price,
241
+ exit_price=exit_price,
242
+ entries=trade.entries,
243
+ exits=trade.exits,
244
+ gross_profit=gross,
245
+ charges=trade.charges,
246
+ net_profit=gross - trade.charges,
247
+ max_favourable=trade.max_favourable,
248
+ max_adverse=trade.max_adverse,
249
+ is_open=trade.closed_on_bar is None,
250
+ )
251
+
252
+
253
+ def trades_of(
254
+ fills: Sequence[RecordedFill],
255
+ charges: Sequence[float],
256
+ marks: Sequence[BarMark],
257
+ contract: Contract,
258
+ ) -> Tuple[Trade, ...]:
259
+ """The round trips a run's fills make up, in the order they opened.
260
+
261
+ ``charges[index]`` is the money ``fills[index]`` was charged, rounded once by
262
+ whoever computed it, so the two travel as one thing and nothing here rounds
263
+ anything a second time. A caller with no cost model supplies no charges at all
264
+ and every trade's charges are zero.
265
+
266
+ The fills are read in ``seq`` order whatever order they are handed in, because
267
+ ``seq`` is the order the engine folded them and a report that depended on the
268
+ order a caller happened to be holding them in would not be reproducible. The
269
+ marks are read in bar order for the same reason, and a bar is marked after
270
+ every fill up to it has been folded, because a fill happens during its bar and
271
+ the close comes after.
272
+ """
273
+ paired = [
274
+ (fill, charges[index] if index < len(charges) else 0.0)
275
+ for index, fill in enumerate(fills)
276
+ ]
277
+ settled = sorted(paired, key=lambda one: one[0].seq)
278
+ built: List[_Building] = []
279
+ live: List[_Building] = []
280
+
281
+ at = 0
282
+ for bar in sorted(marks, key=lambda one: one.bar_index):
283
+ while at < len(settled) and settled[at][0].bar_index <= bar.bar_index:
284
+ _fold(settled[at][0], settled[at][1], built, live)
285
+ at += 1
286
+ # A bar with no close is not a price anything can be marked at. It marks
287
+ # nothing rather than marking zero, which would read as a total loss.
288
+ if bar.close is not None:
289
+ _mark_to(live, bar.close, contract.point_value)
290
+ while at < len(settled):
291
+ _fold(settled[at][0], settled[at][1], built, live)
292
+ at += 1
293
+
294
+ return tuple(_finish(trade, contract) for trade in built)
@@ -0,0 +1,32 @@
1
+ """The conformance adapter: one case in, one JSON object out.
2
+
3
+ ``spec/conformance.md`` section 9 is the whole specification of what is here, and
4
+ ``__main__.py`` next door is the command line that starts it. This package is the
5
+ door and holds nothing of its own.
6
+
7
+ What goes where:
8
+
9
+ - ``page`` what the conformance page fixes, held to it by a test
10
+ - ``spellings`` how a value is written in a case file and in a report
11
+ - ``reading`` one case directory, by the names section 2's table gives it
12
+ - ``expectations`` what the case's own files expect, as channels
13
+ - ``sessions`` the session facts an engine derives from the record
14
+ - ``facts`` the ``chart``, ``pos`` and session entries, which are not the library's
15
+ - ``ordering`` the nine order calls, and the desk that folds a case's frames
16
+ - ``reporting`` what a strategy run's report was folded under
17
+ - ``channels`` the orders, trades and performance channels, as section 4 writes them
18
+ - ``serving`` the seam between the library's entries and the machine
19
+ - ``running`` the engine's answer for one case, with no comparison made
20
+ - ``matching`` the comparison of section 6, and the caps it is held to
21
+ - ``answers`` the three invocations, each to the object it writes
22
+
23
+ **Nothing here decides what this engine can do.** The engine refuses what it
24
+ cannot serve, by name, at load; ``running`` turns that refusal into the
25
+ ``unsupported`` outcome. A list of unsupported features kept in the adapter would
26
+ say what somebody believed, and would go on saying it after the engine grew the
27
+ feature.
28
+ """
29
+
30
+ from .answers import answer_for, describe, invoke, result_for
31
+
32
+ __all__ = ["answer_for", "describe", "invoke", "result_for"]
@@ -0,0 +1,215 @@
1
+ """The three objects ``conformance.md`` section 9 says an adapter writes.
2
+
3
+ adapter --describe the engine's identity
4
+ adapter <case-directory> one case result
5
+ adapter --actual <case-directory> the channels the case asserts, no comparison
6
+
7
+ The third exists so that two engines can be compared with each other rather than
8
+ each with an expected file: a case result carries an outcome and a first
9
+ difference rather than the values, so two adapters reporting ``pass`` prove only
10
+ that both matched a file, which section 10 says is not enough. In that mode this
11
+ adapter makes no comparison, reads no tolerance and reports no outcome.
12
+
13
+ **This engine implements no compiler, and the program arrives compiled.** Section
14
+ 1 covers it: "An implementation that only has an engine (it reads compiled
15
+ programs produced elsewhere) runs the engine half and says so". So the compiled
16
+ program is handed to this process, as the canonical text a host sends it in
17
+ production, and the invocation that carries it is described in ``__main__.py``.
18
+ Two consequences are reported rather than hidden. A case whose assertion is the
19
+ compiler's own diagnostics is ``unsupported``, because those diagnostics are not
20
+ this engine's to claim. And the text goes in through ``load_text``, so the
21
+ canonicity check at the text boundary is exercised on every case rather than
22
+ skipped by handing the engine an object.
23
+
24
+ **The profile claimed is the page's lowest**, and every case this engine cannot
25
+ run is named on the case with the ``unsupported`` outcome. Section 8 offers
26
+ ``engine-only`` for an implementation with no compiler and the runner's own
27
+ vocabulary is the profile table, which does not list it, so the identity carries
28
+ both: the profile the table names, and the flag that says what section 8 would
29
+ have it say. The stage's report records that as a defect of the page rather than
30
+ leaving it to be discovered.
31
+ """
32
+
33
+ import json
34
+ import sys
35
+ import tomllib
36
+ from pathlib import Path
37
+ from typing import Any, Dict, Optional
38
+
39
+ from ..version import FORMAT, LANGUAGE_VERSIONS
40
+ from .expectations import expected_channels
41
+ from .matching import compare_channels, tolerance_from
42
+ from .page import PROFILES
43
+ from .reading import read_case
44
+ from .running import run_case
45
+ from .spellings import Malformed
46
+
47
+ #: The distribution file beside the package, which states the version once.
48
+ _PROJECT = "pyproject.toml"
49
+
50
+ #: Section 8: what this implementation claims. The profile that covers the cases
51
+ #: it runs, which is the one a case it would report ``unsupported`` is not in.
52
+ #:
53
+ #: The claim is read against the runner rather than against the table's own
54
+ #: sentence, and the two do not say the same thing. Section 8 makes a profile
55
+ #: cumulative, so ``strategy`` reads as "everything ``chart`` covers, and orders
56
+ #: as well", and this engine draws nothing: a surface case would be answered
57
+ #: ``unsupported`` naming the channel. The runner's vocabulary is that table, so
58
+ #: the honest alternatives are to claim this and report every channel it cannot
59
+ #: answer by name, or to claim ``core`` and have every strategy case skipped,
60
+ #: which is a suite that proves nothing about the engine that runs the money.
61
+ #: The first is chosen, the shortfall is named on the case rather than in a
62
+ #: footnote, and the stage's report records the page's missing profile as a
63
+ #: defect of the page.
64
+ PROFILE = PROFILES[2]
65
+
66
+
67
+ def _distribution() -> Dict[str, Any]:
68
+ """``pyproject.toml``, read from beside the package, or a refusal saying so.
69
+
70
+ The version is a fact of the release and is written in the distribution
71
+ file; ``scripts/check-python.mjs`` already holds that file and the package
72
+ manifest equal, so reading it here adds no third copy. An installed package
73
+ that does not carry the file cannot prove its version, and an identity with
74
+ an invented version in it is worse than an adapter that says which file it
75
+ could not find.
76
+ """
77
+ path = Path(__file__).resolve().parent.parent.parent / _PROJECT
78
+ try:
79
+ with path.open("rb") as file:
80
+ return tomllib.load(file)["project"]
81
+ except (OSError, KeyError, ValueError) as reason:
82
+ raise Malformed(
83
+ f"{path} could not be read, and section 9 has an adapter answer with the engine's own "
84
+ f"name and version: {reason}"
85
+ ) from None
86
+
87
+
88
+ def describe() -> Dict[str, Any]:
89
+ """The identity section 9's table asks for, and the one flag section 8 adds."""
90
+ project = _distribution()
91
+ return {
92
+ "name": project["name"],
93
+ "version": project["version"],
94
+ "profile": PROFILE,
95
+ "languageVersions": list(LANGUAGE_VERSIONS),
96
+ # The compiled program format this engine implements
97
+ # (``compiled-program.md`` section 9). Section 9 of the conformance page
98
+ # names the field and fixes no meaning for it.
99
+ "schemaVersion": FORMAT,
100
+ # Section 8: an implementation that runs compiled programs and implements
101
+ # no compiler reports engine-only, and its report says so.
102
+ "engineOnly": True,
103
+ }
104
+
105
+
106
+ def _program_from(envelope: Any) -> Any:
107
+ """The compiled program the invocation carried, or what it carried instead."""
108
+ if not isinstance(envelope, dict):
109
+ raise Malformed(
110
+ "this adapter is handed the compiled program on standard input, as one JSON object, "
111
+ "and nothing readable arrived"
112
+ )
113
+ held = envelope.get("program")
114
+ if isinstance(held, str):
115
+ return held
116
+ if "diagnostics" in envelope:
117
+ return None
118
+ raise Malformed(
119
+ f"the invocation carried neither a program nor the diagnostics of a compile: "
120
+ f"{sorted(envelope)}"
121
+ )
122
+
123
+
124
+ def answer_for(directory: str, envelope: Any) -> Dict[str, Any]:
125
+ """``--actual``: what this engine computed, and what it could not compute.
126
+
127
+ ``{id, channels, unsupported}``, or ``{id, error}`` for a case that cannot be
128
+ run at all. No comparison is made and no tolerance is read.
129
+ """
130
+ try:
131
+ case = read_case(directory)
132
+ except Malformed as reason:
133
+ return {"id": None, "error": str(reason)}
134
+ try:
135
+ program = _program_from(envelope)
136
+ if program is None:
137
+ return {
138
+ "id": case.identity,
139
+ "channels": {},
140
+ "unsupported": [
141
+ "the compiler (section 1): the case's script did not compile, this engine "
142
+ "implements none, and a diagnostic another compiler raised is not this "
143
+ "engine's to assert"
144
+ ],
145
+ }
146
+ found = run_case(case, program)
147
+ except Malformed as reason:
148
+ return {"id": case.identity, "error": str(reason)}
149
+ return {
150
+ "id": case.identity,
151
+ "channels": found.channels,
152
+ "unsupported": found.unsupported,
153
+ "columnTypes": found.column_types,
154
+ }
155
+
156
+
157
+ def result_for(directory: str, envelope: Any) -> Dict[str, Any]:
158
+ """The plain invocation: the answer above, compared with the case's own files.
159
+
160
+ One of section 9's outcomes, with the first difference on a failure. A case
161
+ this engine cannot run is ``unsupported`` with the feature named, which
162
+ section 9 says is neither a pass nor a failure and is counted separately.
163
+ """
164
+ try:
165
+ case = read_case(directory)
166
+ except Malformed as reason:
167
+ return {"id": None, "outcome": "error", "reason": str(reason)}
168
+ answer = answer_for(directory, envelope)
169
+ if "error" in answer:
170
+ return {"id": answer["id"], "outcome": "error", "reason": answer["error"]}
171
+ if answer["unsupported"]:
172
+ return {
173
+ "id": answer["id"],
174
+ "outcome": "unsupported",
175
+ "feature": "; ".join(answer["unsupported"]),
176
+ }
177
+ tolerance, refused = tolerance_from(case.declared.get("tolerance"))
178
+ if tolerance is None:
179
+ return {"id": case.identity, "outcome": "error", "reason": f"case.json: {refused}"}
180
+ expected, missing = expected_channels(case, answer["columnTypes"])
181
+ if expected is None:
182
+ return {"id": case.identity, "outcome": "error", "reason": missing}
183
+ compared = compare_channels(case.asserts, answer["channels"], expected, tolerance)
184
+ return {"id": case.identity, **compared}
185
+
186
+
187
+ def read_envelope(text: str) -> Any:
188
+ """The JSON object the invocation carried, or a refusal naming what arrived."""
189
+ try:
190
+ return json.loads(text) if text.strip() != "" else {}
191
+ except ValueError as reason:
192
+ raise Malformed(f"the invocation's standard input is not JSON: {reason}") from None
193
+
194
+
195
+ def stdin_text() -> str:
196
+ """Everything the caller wrote, as text. Empty when nothing was piped in."""
197
+ if sys.stdin is None or sys.stdin.isatty():
198
+ return ""
199
+ return sys.stdin.read()
200
+
201
+
202
+ def invoke(arguments: Any) -> Optional[Dict[str, Any]]:
203
+ """One of the three invocations, to the one object it writes, or nothing.
204
+
205
+ Nothing means the argument list is not one of the three, which
206
+ ``__main__.py`` refuses with a non-zero exit: no object it could write would
207
+ be a case result, and section 9 keeps a non-zero exit meaning a crash.
208
+ """
209
+ if list(arguments) == ["--describe"]:
210
+ return describe()
211
+ if len(arguments) == 2 and arguments[0] == "--actual":
212
+ return answer_for(arguments[1], read_envelope(stdin_text()))
213
+ if len(arguments) == 1 and not arguments[0].startswith("--"):
214
+ return result_for(arguments[0], read_envelope(stdin_text()))
215
+ return None
@@ -0,0 +1,137 @@
1
+ """The three strategy channels, in the encoding ``conformance.md`` section 4 gives them.
2
+
3
+ A channel of ``expected.json`` is "an ordered list, and each element is a flat
4
+ object of named fields". ``orders`` is a ledger row of ``stdlib.md`` 17.7,
5
+ ``trades`` is a round trip, and ``performance`` is a list of one flat object
6
+ holding the run's summary and nothing nested.
7
+
8
+ **An intent is named by its ordinal and never by an id.** Section 3: "a case
9
+ cannot know the id an engine minted and must not depend on its spelling". The
10
+ ordinal counts every intent the run placed, so a bracket that appended no row
11
+ still takes one, and the gaps in an ``orders`` channel are where the brackets
12
+ were.
13
+
14
+ **Nothing here computes.** Every figure is read off a row, a trade or a summary
15
+ the layers next door folded, in the order they folded them, so a difference
16
+ between two engines is a difference in the fold rather than in a projection.
17
+ """
18
+
19
+ from typing import Any, Dict, List, Optional, Sequence
20
+
21
+ from ..accounting import Summary, Trade
22
+ from ..strategy import Intents, LedgerRow
23
+
24
+
25
+ def order_row(row: LedgerRow, ordinal: int, qty_type: str) -> Dict[str, Any]:
26
+ """One ledger row, as a case records one.
27
+
28
+ The rejection is absence where there is none, because absence and the empty
29
+ string are different answers everywhere else in this language and a row a
30
+ destination said nothing about is not a row it rejected with no words.
31
+ """
32
+ return {
33
+ "intent": ordinal,
34
+ "orderRef": row.order_ref,
35
+ "tag": row.tag,
36
+ "leg": row.leg,
37
+ "positionRef": row.position_ref,
38
+ "symbol": row.instrument.symbol,
39
+ "exchange": row.instrument.exchange,
40
+ "product": row.product,
41
+ "side": row.side,
42
+ "qty": row.qty,
43
+ "qtyType": qty_type,
44
+ "type": row.order_type,
45
+ "price": row.price,
46
+ "trigger": row.trigger,
47
+ "status": row.status,
48
+ "filledQty": row.filled_qty,
49
+ "avgFillPrice": row.avg_fill_price,
50
+ "rejection": None if row.rejection == "" else row.rejection,
51
+ "placedAt": row.placed_at,
52
+ "updatedAt": row.updated_at,
53
+ "units": row.units,
54
+ }
55
+
56
+
57
+ def orders_channel(rows: Sequence[LedgerRow], intents: Intents) -> List[Dict[str, Any]]:
58
+ """Every row of the ledger, oldest first, which is the order it was sent in."""
59
+ found: List[Dict[str, Any]] = []
60
+ for row in rows:
61
+ intent = intents.by_id(row.intent_id)
62
+ found.append(
63
+ order_row(
64
+ row,
65
+ intents.ordinal_of(row.intent_id),
66
+ "" if intent is None else intent.qty_type,
67
+ )
68
+ )
69
+ return found
70
+
71
+
72
+ def trade_row(trade: Trade) -> Dict[str, Any]:
73
+ """One round trip, as a case records one."""
74
+ return {
75
+ "barsHeld": trade.bars_held,
76
+ "charges": trade.charges,
77
+ "closedAt": trade.closed_at,
78
+ "closedOnBar": trade.closed_on_bar,
79
+ "entries": trade.entries,
80
+ "entryPrice": trade.entry_price,
81
+ "exitPrice": trade.exit_price,
82
+ "exits": trade.exits,
83
+ "grossProfit": trade.gross_profit,
84
+ "index": trade.index,
85
+ "isOpen": trade.is_open,
86
+ "maxAdverse": trade.max_adverse,
87
+ "maxFavourable": trade.max_favourable,
88
+ "netProfit": trade.net_profit,
89
+ "openedAt": trade.opened_at,
90
+ "openedOnBar": trade.opened_on_bar,
91
+ "positionRef": trade.position_ref,
92
+ "side": trade.side,
93
+ "units": trade.units,
94
+ }
95
+
96
+
97
+ def summary_row(summary: Summary) -> Dict[str, Any]:
98
+ """The run's summary, as a case records it: one flat object and nothing nested."""
99
+ return {
100
+ "averageBarsHeld": summary.average_bars_held,
101
+ "averageLoss": summary.average_loss,
102
+ "averageWin": summary.average_win,
103
+ "barCount": summary.bar_count,
104
+ "barsInMarket": summary.bars_in_market,
105
+ "capital": summary.capital,
106
+ "charges": summary.charges,
107
+ "currency": summary.currency,
108
+ "expectancy": summary.expectancy,
109
+ "expectancyStandardError": summary.expectancy_standard_error,
110
+ "grossLoss": summary.gross_loss,
111
+ "grossProfit": summary.gross_profit,
112
+ "longestDrawdownBars": summary.longest_drawdown_bars,
113
+ "losses": summary.losses,
114
+ "maxDrawdown": summary.max_drawdown,
115
+ "maxDrawdownAt": summary.max_drawdown_at,
116
+ "maxDrawdownPercent": summary.max_drawdown_percent,
117
+ "maxRunUp": summary.max_run_up,
118
+ "maxRunUpAt": summary.max_run_up_at,
119
+ "maxRunUpPercent": summary.max_run_up_percent,
120
+ "netProfit": summary.net_profit,
121
+ "openTradeCount": summary.open_trade_count,
122
+ "profitFactor": summary.profit_factor,
123
+ "returnPercent": summary.return_percent,
124
+ "scratches": summary.scratches,
125
+ "tradeCount": summary.trade_count,
126
+ "winRate": summary.win_rate,
127
+ "wins": summary.wins,
128
+ }
129
+
130
+
131
+ def performance_channel(summary: Optional[Summary]) -> List[Dict[str, Any]]:
132
+ """The summary as the one element of an ordered list, which is what the channel is.
133
+
134
+ A list of one rather than an object, so that a reader and a runner need one
135
+ shape for every channel rather than two.
136
+ """
137
+ return [] if summary is None else [summary_row(summary)]