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.
- openscript/__init__.py +40 -0
- openscript/__main__.py +62 -0
- openscript/accounting/__init__.py +74 -0
- openscript/accounting/analysis.py +174 -0
- openscript/accounting/charges.py +397 -0
- openscript/accounting/equity.py +234 -0
- openscript/accounting/report.py +82 -0
- openscript/accounting/shapes.py +74 -0
- openscript/accounting/statistics.py +300 -0
- openscript/accounting/trades.py +294 -0
- openscript/adapter/__init__.py +32 -0
- openscript/adapter/answers.py +215 -0
- openscript/adapter/channels.py +137 -0
- openscript/adapter/expectations.py +67 -0
- openscript/adapter/facts.py +127 -0
- openscript/adapter/matching.py +257 -0
- openscript/adapter/ordering.py +187 -0
- openscript/adapter/page.py +130 -0
- openscript/adapter/reading.py +357 -0
- openscript/adapter/reporting.py +244 -0
- openscript/adapter/running.py +449 -0
- openscript/adapter/serving.py +229 -0
- openscript/adapter/sessions.py +168 -0
- openscript/adapter/spellings.py +184 -0
- openscript/bars.py +157 -0
- openscript/budget.py +342 -0
- openscript/canonical.py +192 -0
- openscript/civil.py +196 -0
- openscript/contracts.py +165 -0
- openscript/dates.py +302 -0
- openscript/diagnostics.py +104 -0
- openscript/hours.py +165 -0
- openscript/inputs.py +239 -0
- openscript/intervals.py +60 -0
- openscript/library/__init__.py +76 -0
- openscript/library/arithmetic.py +128 -0
- openscript/library/averages.py +133 -0
- openscript/library/bars.py +60 -0
- openscript/library/bookkeeping.py +166 -0
- openscript/library/code_points.py +85 -0
- openscript/library/colour.py +202 -0
- openscript/library/composites.py +208 -0
- openscript/library/counting.py +218 -0
- openscript/library/deviation.py +155 -0
- openscript/library/elementary.py +206 -0
- openscript/library/extremes.py +122 -0
- openscript/library/flows.py +220 -0
- openscript/library/momentum.py +203 -0
- openscript/library/number_text.py +223 -0
- openscript/library/prices.py +36 -0
- openscript/library/ranges.py +105 -0
- openscript/library/rounding.py +123 -0
- openscript/library/series.py +213 -0
- openscript/library/stateful.py +442 -0
- openscript/library/stateless.py +261 -0
- openscript/library/strength.py +180 -0
- openscript/library/strings.py +228 -0
- openscript/library/trend.py +260 -0
- openscript/library/values.py +91 -0
- openscript/logbook.py +119 -0
- openscript/machine.py +499 -0
- openscript/memory.py +204 -0
- openscript/opcodes.py +166 -0
- openscript/program.py +146 -0
- openscript/run.py +368 -0
- openscript/strategy/__init__.py +78 -0
- openscript/strategy/calls.py +201 -0
- openscript/strategy/closable.py +182 -0
- openscript/strategy/fills.py +131 -0
- openscript/strategy/holdings.py +277 -0
- openscript/strategy/intents.py +162 -0
- openscript/strategy/ledger.py +270 -0
- openscript/strategy/placing.py +206 -0
- openscript/strategy/positions.py +124 -0
- openscript/strategy/refusals.py +293 -0
- openscript/strategy/rows.py +219 -0
- openscript/strategy/sizing.py +229 -0
- openscript/strategy/statuses.py +65 -0
- openscript/surface/__init__.py +115 -0
- openscript/surface/bands.py +103 -0
- openscript/surface/levels.py +44 -0
- openscript/surface/marks.py +52 -0
- openscript/surface/paints.py +58 -0
- openscript/surface/plots.py +44 -0
- openscript/surface/published.py +119 -0
- openscript/values.py +210 -0
- openscript/verify.py +301 -0
- openscript/verify_code.py +290 -0
- openscript/verify_requests.py +271 -0
- openscript/verify_shape.py +162 -0
- openscript/verify_tables.py +256 -0
- openscript/version.py +39 -0
- openscript/zones.py +118 -0
- openscript-0.4.0.dist-info/METADATA +82 -0
- openscript-0.4.0.dist-info/RECORD +97 -0
- openscript-0.4.0.dist-info/WHEEL +5 -0
- 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)]
|