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,130 @@
|
|
|
1
|
+
"""What ``spec/conformance.md`` fixes, carried here because the engine reads no page.
|
|
2
|
+
|
|
3
|
+
Every value below is printed in that document, and a copy is a fact stated twice
|
|
4
|
+
unless something holds the two equal. So the arrangement is the one section 6
|
|
5
|
+
already uses for the tolerance caps, in the page's own words: the figures "are
|
|
6
|
+
read out of this section by a test and held to the constants the projection
|
|
7
|
+
carries, because the engine reads no page". ``tests/test_adapter_page.py`` is
|
|
8
|
+
that test here. It reads the document and fails the build the day a value below
|
|
9
|
+
stops being the page's.
|
|
10
|
+
|
|
11
|
+
The reason the constants are not simply read at run time is the same reason
|
|
12
|
+
``version.py`` next door carries two numbers rather than reading them: a package
|
|
13
|
+
a host installs does not carry the specification, and an adapter that could only
|
|
14
|
+
answer with the repository beside it would be an adapter nobody outside this
|
|
15
|
+
tree could run.
|
|
16
|
+
|
|
17
|
+
Nothing here is a default this file chose. Section 3's instrument block is that
|
|
18
|
+
section's, "chosen to be boring rather than realistic"; the channels are section
|
|
19
|
+
2's ``asserts`` vocabulary; the outcomes and the profiles are sections 9 and 8.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Any, Dict, Tuple
|
|
23
|
+
|
|
24
|
+
#: Section 2: the file names a case directory may hold. A file the table does
|
|
25
|
+
#: not name is not input, so a directory holding one is malformed rather than
|
|
26
|
+
#: read past.
|
|
27
|
+
CASE_FILES: Tuple[str, ...] = (
|
|
28
|
+
"case.json",
|
|
29
|
+
"script.os",
|
|
30
|
+
"bars.csv",
|
|
31
|
+
"expected.csv",
|
|
32
|
+
"expected.json",
|
|
33
|
+
"instrument.json",
|
|
34
|
+
"settings.json",
|
|
35
|
+
"backtest.json",
|
|
36
|
+
"ticks.csv",
|
|
37
|
+
"frames.csv",
|
|
38
|
+
"notes.md",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
#: The one row of that table written with a name in it: a secondary series, one
|
|
42
|
+
#: file per series the script asks for.
|
|
43
|
+
SECONDARY_PREFIX = "bars."
|
|
44
|
+
SECONDARY_SUFFIX = ".csv"
|
|
45
|
+
|
|
46
|
+
#: Section 2: the channels a case may assert. A case asserts the ones it names
|
|
47
|
+
#: and no others, which is what keeps a change to one output from failing a case
|
|
48
|
+
#: about something else.
|
|
49
|
+
CHANNELS: Tuple[str, ...] = (
|
|
50
|
+
"diagnostics",
|
|
51
|
+
"values",
|
|
52
|
+
"markers",
|
|
53
|
+
"fills",
|
|
54
|
+
"levels",
|
|
55
|
+
"barColors",
|
|
56
|
+
"background",
|
|
57
|
+
"table",
|
|
58
|
+
"drawings",
|
|
59
|
+
"alerts",
|
|
60
|
+
"orders",
|
|
61
|
+
"trades",
|
|
62
|
+
"performance",
|
|
63
|
+
"log",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
#: Section 9: the outcomes a case result may carry. The runner refuses one this
|
|
67
|
+
#: list does not name, and so does this adapter's own test.
|
|
68
|
+
OUTCOMES: Tuple[str, ...] = ("pass", "fail", "nonFinite", "error", "unsupported", "skipped")
|
|
69
|
+
|
|
70
|
+
#: Section 8: the profiles, in the order they include one another.
|
|
71
|
+
PROFILES: Tuple[str, ...] = ("core", "chart", "strategy")
|
|
72
|
+
|
|
73
|
+
#: Section 3: the header of ``bars.csv``, in this order. An extra column is an
|
|
74
|
+
#: error rather than ignored, so a typo in a header cannot drop an input.
|
|
75
|
+
BARS_HEADER: Tuple[str, ...] = ("time", "open", "high", "low", "close", "volume")
|
|
76
|
+
|
|
77
|
+
#: Section 3: the fields of one row of ``frames.csv``, the last three optional
|
|
78
|
+
#: and dropped from the right, so a header the page allows is a prefix of this.
|
|
79
|
+
FRAMES_HEADER: Tuple[str, ...] = (
|
|
80
|
+
"afterBar",
|
|
81
|
+
"intent",
|
|
82
|
+
"status",
|
|
83
|
+
"filledQty",
|
|
84
|
+
"avgFillPrice",
|
|
85
|
+
"orderRef",
|
|
86
|
+
"text",
|
|
87
|
+
"time",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
#: Section 4: how an absent value is written in a case file, in every column of
|
|
91
|
+
#: every one of them.
|
|
92
|
+
ABSENT_TEXT = "none"
|
|
93
|
+
|
|
94
|
+
#: Section 6: the loosest tolerance that is still a conformance case. A case
|
|
95
|
+
#: past either bound is reported ``error`` rather than run.
|
|
96
|
+
TOLERANCE_CAP_ABS = 1e-12
|
|
97
|
+
TOLERANCE_CAP_REL = 1e-9
|
|
98
|
+
|
|
99
|
+
#: Section 3: the instrument facts a case that states no ``instrument.json``
|
|
100
|
+
#: runs under.
|
|
101
|
+
DEFAULT_INSTRUMENT: Dict[str, Any] = {
|
|
102
|
+
"symbol": "TEST",
|
|
103
|
+
"exchange": "TEST",
|
|
104
|
+
"interval": "60",
|
|
105
|
+
"timezone": "UTC",
|
|
106
|
+
"tickSize": 0.01,
|
|
107
|
+
"lotSize": 1,
|
|
108
|
+
"hasVolume": True,
|
|
109
|
+
"session": {"start": "00:00", "end": "24:00", "days": [1, 2, 3, 4, 5, 6, 7]},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#: Section 3: the fields of ``backtest.json``, all three always stated.
|
|
113
|
+
BACKTEST_FIELDS: Tuple[str, ...] = ("digits", "costs", "range")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_case_file(name: str) -> bool:
|
|
117
|
+
"""Whether section 2's table names this file, including a secondary series."""
|
|
118
|
+
if name in CASE_FILES:
|
|
119
|
+
return True
|
|
120
|
+
return (
|
|
121
|
+
name.startswith(SECONDARY_PREFIX)
|
|
122
|
+
and name.endswith(SECONDARY_SUFFIX)
|
|
123
|
+
and len(name) > len(SECONDARY_PREFIX) + len(SECONDARY_SUFFIX)
|
|
124
|
+
and name != "bars.csv"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_secondary(name: str) -> bool:
|
|
129
|
+
"""Whether this file is a secondary series rather than the case's own bars."""
|
|
130
|
+
return name != "bars.csv" and is_case_file(name) and name.startswith(SECONDARY_PREFIX)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""One case directory, read by the names ``conformance.md`` section 2 gives it.
|
|
2
|
+
|
|
3
|
+
Section 2's table "is the whole of a case directory. A runner reads no other
|
|
4
|
+
file from it, and a file the table does not name is not input", so a directory
|
|
5
|
+
holding a file the table does not name is refused rather than read past: a case
|
|
6
|
+
that needed something no row covers is a case the suite cannot run until the row
|
|
7
|
+
exists, and an adapter that quietly ignored the file would run it under
|
|
8
|
+
different input from the engine next door.
|
|
9
|
+
|
|
10
|
+
Every refusal here is one sentence naming the file and what was wrong with it,
|
|
11
|
+
and every one of them reaches the caller as ``error`` (section 9): a malformed
|
|
12
|
+
case is not a failure of the engine, and reporting it as one would put the blame
|
|
13
|
+
on whoever is running the suite rather than on whoever wrote the directory.
|
|
14
|
+
|
|
15
|
+
**Nothing here is computed.** Bars are the file's, settings are the file's,
|
|
16
|
+
instrument facts are the file's or, when the file is absent, section 3's default
|
|
17
|
+
set carried in ``page.py``. That is the reason a case is reproducible on a
|
|
18
|
+
machine that has never seen this repository.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
import io
|
|
23
|
+
import json
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
27
|
+
|
|
28
|
+
from .page import (
|
|
29
|
+
ABSENT_TEXT,
|
|
30
|
+
BACKTEST_FIELDS,
|
|
31
|
+
BARS_HEADER,
|
|
32
|
+
CHANNELS,
|
|
33
|
+
DEFAULT_INSTRUMENT,
|
|
34
|
+
FRAMES_HEADER,
|
|
35
|
+
PROFILES,
|
|
36
|
+
is_case_file,
|
|
37
|
+
is_secondary,
|
|
38
|
+
)
|
|
39
|
+
from .spellings import Malformed, read_number, read_whole
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Bar:
|
|
44
|
+
"""One row of ``bars.csv``, with an absent field held as absence."""
|
|
45
|
+
|
|
46
|
+
time: int
|
|
47
|
+
open: Optional[float]
|
|
48
|
+
high: Optional[float]
|
|
49
|
+
low: Optional[float]
|
|
50
|
+
close: Optional[float]
|
|
51
|
+
volume: Optional[float]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Frame:
|
|
56
|
+
"""One row of ``frames.csv``, with the boundary it is delivered at.
|
|
57
|
+
|
|
58
|
+
``intent`` is an ordinal and not an id: 1 is the first intent the run placed.
|
|
59
|
+
A case cannot know the id an engine minted and must not depend on its
|
|
60
|
+
spelling, so the ordinal is what a case names an order by and the driver maps
|
|
61
|
+
it to whatever this engine minted.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
after_bar: int
|
|
65
|
+
intent: int
|
|
66
|
+
status: str
|
|
67
|
+
filled_qty: float
|
|
68
|
+
avg_fill_price: Optional[float]
|
|
69
|
+
order_ref: str
|
|
70
|
+
text: str
|
|
71
|
+
#: The destination's own instant for this frame, absent where it stated
|
|
72
|
+
#: none. ``stdlib.md`` 17.7 folds a row's ``updatedAt`` from it, so a reader
|
|
73
|
+
#: that dropped this column would hand the ledger frames that leave that
|
|
74
|
+
#: field where the placement put it, whatever the case says its destination
|
|
75
|
+
#: did. Defaulted because a file written before the column existed is a file
|
|
76
|
+
#: whose frames state no instant.
|
|
77
|
+
time: Optional[float] = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Case:
|
|
82
|
+
"""A case directory, read. Every field is a file's, or the page's default."""
|
|
83
|
+
|
|
84
|
+
directory: Path
|
|
85
|
+
declared: Dict[str, Any]
|
|
86
|
+
script: str
|
|
87
|
+
bars: Optional[List[Bar]] = None
|
|
88
|
+
expected_columns: Tuple[str, ...] = ()
|
|
89
|
+
expected_rows: List[List[str]] = field(default_factory=list)
|
|
90
|
+
expected_json: Optional[Dict[str, Any]] = None
|
|
91
|
+
instrument: Dict[str, Any] = field(default_factory=dict)
|
|
92
|
+
stated_instrument: bool = False
|
|
93
|
+
settings: Dict[str, Any] = field(default_factory=dict)
|
|
94
|
+
backtest: Optional[Dict[str, Any]] = None
|
|
95
|
+
ticks: bool = False
|
|
96
|
+
frames: Optional[Tuple[Frame, ...]] = None
|
|
97
|
+
secondary: Tuple[str, ...] = ()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def identity(self) -> str:
|
|
101
|
+
return str(self.declared.get("id"))
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def asserts(self) -> Tuple[str, ...]:
|
|
105
|
+
return tuple(self.declared["asserts"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _text_of(path: Path) -> str:
|
|
109
|
+
try:
|
|
110
|
+
return path.read_text(encoding="utf-8")
|
|
111
|
+
except OSError as reason:
|
|
112
|
+
raise Malformed(f"{path.name} could not be read: {reason}") from None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _json_of(path: Path) -> Any:
|
|
116
|
+
try:
|
|
117
|
+
return json.loads(_text_of(path))
|
|
118
|
+
except ValueError as reason:
|
|
119
|
+
raise Malformed(f"{path.name} is not JSON: {reason}") from None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _rows_of(text: str, name: str) -> List[List[str]]:
|
|
123
|
+
"""A comma separated file as rows, with the blank lines refused not dropped.
|
|
124
|
+
|
|
125
|
+
Section 3 says no blank lines, so one is a defect in the file rather than
|
|
126
|
+
something to skip: a dropped row and an empty row look the same afterwards,
|
|
127
|
+
and the whole reason the expected file carries a bar index is that a dropped
|
|
128
|
+
row is caught where it was dropped.
|
|
129
|
+
"""
|
|
130
|
+
rows = [row for row in csv.reader(io.StringIO(text))]
|
|
131
|
+
while rows and rows[-1] == []:
|
|
132
|
+
rows.pop()
|
|
133
|
+
for at, row in enumerate(rows):
|
|
134
|
+
if row == []:
|
|
135
|
+
raise Malformed(f"{name} line {at + 1} is blank, and a case file holds no blank line")
|
|
136
|
+
if not rows:
|
|
137
|
+
raise Malformed(f"{name} is empty, and a case file that holds nothing is not input")
|
|
138
|
+
return rows
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _header_held(row: Sequence[str], wanted: Sequence[str], name: str) -> None:
|
|
142
|
+
if tuple(row) != tuple(wanted):
|
|
143
|
+
raise Malformed(
|
|
144
|
+
f"{name} has the header {','.join(row)} and section 3 gives it {','.join(wanted)}. "
|
|
145
|
+
"An extra or missing column is an error rather than ignored, so a typo in a header "
|
|
146
|
+
"cannot silently drop an input"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_bars(text: str, name: str = "bars.csv") -> List[Bar]:
|
|
151
|
+
"""``bars.csv``: the header section 3 prints, then one row per bar."""
|
|
152
|
+
rows = _rows_of(text, name)
|
|
153
|
+
_header_held(rows[0], BARS_HEADER, name)
|
|
154
|
+
bars: List[Bar] = []
|
|
155
|
+
for at, row in enumerate(rows[1:]):
|
|
156
|
+
where = f"{name} line {at + 2}"
|
|
157
|
+
if len(row) != len(BARS_HEADER):
|
|
158
|
+
raise Malformed(f"{where} holds {len(row)} fields and the header names {len(BARS_HEADER)}")
|
|
159
|
+
time = read_whole(row[0], f"{where}, time")
|
|
160
|
+
prices = [
|
|
161
|
+
None if cell == ABSENT_TEXT else read_number(cell, f"{where}, {BARS_HEADER[which + 1]}")
|
|
162
|
+
for which, cell in enumerate(row[1:])
|
|
163
|
+
]
|
|
164
|
+
if bars and time <= bars[-1].time:
|
|
165
|
+
raise Malformed(
|
|
166
|
+
f"{where} is at {time} and the bar before it is at {bars[-1].time}. A bar's open "
|
|
167
|
+
"time is strictly increasing, and a run over bars in another order is not the run "
|
|
168
|
+
"the expected file came from"
|
|
169
|
+
)
|
|
170
|
+
bars.append(Bar(time, prices[0], prices[1], prices[2], prices[3], prices[4]))
|
|
171
|
+
if not bars:
|
|
172
|
+
raise Malformed(f"{name} holds a header and no bar")
|
|
173
|
+
return bars
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_frames(text: str, name: str = "frames.csv") -> Tuple[Frame, ...]:
|
|
177
|
+
"""``frames.csv``: one row per frame, in the order the destination sent them.
|
|
178
|
+
|
|
179
|
+
Section 3 gives the header and makes the last three columns optional, dropped
|
|
180
|
+
from the right, and "an omitted column is absent on every row". An extra
|
|
181
|
+
column is an error, as in ``bars.csv``, and so is a column out of order: the
|
|
182
|
+
fields are read by position, and a file that named them in another order
|
|
183
|
+
would be folded into another ledger without anything saying so.
|
|
184
|
+
|
|
185
|
+
Nothing is sorted. "Several rows may name one bar and are delivered in file
|
|
186
|
+
order, which is how a case orders two frames that cross", so the file's order
|
|
187
|
+
is the delivery order and this reader is the last place it could be lost.
|
|
188
|
+
"""
|
|
189
|
+
rows = _rows_of(text, name)
|
|
190
|
+
header = tuple(rows[0])
|
|
191
|
+
if header != FRAMES_HEADER[: len(header)]:
|
|
192
|
+
raise Malformed(
|
|
193
|
+
f"{name} has the header {','.join(header)} and section 3 gives it "
|
|
194
|
+
f"{','.join(FRAMES_HEADER)}, of which only the last three columns may be left out"
|
|
195
|
+
)
|
|
196
|
+
found: List[Frame] = []
|
|
197
|
+
for at, row in enumerate(rows[1:]):
|
|
198
|
+
where = f"{name} line {at + 2}"
|
|
199
|
+
if len(row) != len(header):
|
|
200
|
+
raise Malformed(f"{where} holds {len(row)} fields and the header names {len(header)}")
|
|
201
|
+
cell = dict(zip(header, row))
|
|
202
|
+
price = cell["avgFillPrice"]
|
|
203
|
+
instant = cell.get("time", ABSENT_TEXT)
|
|
204
|
+
found.append(
|
|
205
|
+
Frame(
|
|
206
|
+
after_bar=read_whole(cell["afterBar"], f"{where}, afterBar"),
|
|
207
|
+
intent=read_whole(cell["intent"], f"{where}, intent"),
|
|
208
|
+
status=cell["status"],
|
|
209
|
+
filled_qty=read_number(cell["filledQty"], f"{where}, filledQty"),
|
|
210
|
+
avg_fill_price=(
|
|
211
|
+
None if price == ABSENT_TEXT else read_number(price, f"{where}, avgFillPrice")
|
|
212
|
+
),
|
|
213
|
+
order_ref=cell.get("orderRef", ""),
|
|
214
|
+
text=cell.get("text", ""),
|
|
215
|
+
time=(
|
|
216
|
+
None if instant == ABSENT_TEXT else read_number(instant, f"{where}, time")
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
return tuple(found)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _read_expected_csv(text: str) -> Tuple[Tuple[str, ...], List[List[str]]]:
|
|
224
|
+
"""``expected.csv``: the bar index, then one column per asserted channel.
|
|
225
|
+
|
|
226
|
+
The ``bar`` column is redundant on purpose and is held to the row position
|
|
227
|
+
here, which is section 4's own reason for it: a dropped row is caught at the
|
|
228
|
+
row it was dropped at rather than at the end.
|
|
229
|
+
"""
|
|
230
|
+
rows = _rows_of(text, "expected.csv")
|
|
231
|
+
header = rows[0]
|
|
232
|
+
if not header or header[0] != "bar":
|
|
233
|
+
raise Malformed("expected.csv: the first column is the zero-based bar index, named bar")
|
|
234
|
+
for at, row in enumerate(rows[1:]):
|
|
235
|
+
where = f"expected.csv line {at + 2}"
|
|
236
|
+
if len(row) != len(header):
|
|
237
|
+
raise Malformed(f"{where} holds {len(row)} fields and the header names {len(header)}")
|
|
238
|
+
if read_whole(row[0], f"{where}, bar") != at:
|
|
239
|
+
raise Malformed(
|
|
240
|
+
f"{where} says it is bar {row[0]} and it is at row {at}. The index is redundant so "
|
|
241
|
+
"that a dropped row is caught where it was dropped"
|
|
242
|
+
)
|
|
243
|
+
return tuple(header[1:]), [row[1:] for row in rows[1:]]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _instrument_from(held: Any) -> Dict[str, Any]:
|
|
247
|
+
if not isinstance(held, dict):
|
|
248
|
+
raise Malformed("instrument.json is not an object of instrument facts")
|
|
249
|
+
if not isinstance(held.get("hasVolume"), bool):
|
|
250
|
+
raise Malformed(
|
|
251
|
+
"instrument.json states no hasVolume. host-interface.md 4.1 requires the volume flag "
|
|
252
|
+
"of every host, and it is the one fact an engine does not refuse a run without, so a "
|
|
253
|
+
"file that omits it would hand the engine a study the expected output did not come from"
|
|
254
|
+
)
|
|
255
|
+
return dict(held)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _backtest_from(held: Any) -> Dict[str, Any]:
|
|
259
|
+
"""``backtest.json``, held to the three fields section 3 names and their shapes."""
|
|
260
|
+
if not isinstance(held, dict):
|
|
261
|
+
raise Malformed("backtest.json is not an object")
|
|
262
|
+
for key in held:
|
|
263
|
+
if key not in BACKTEST_FIELDS:
|
|
264
|
+
raise Malformed(f"backtest.json states {key}, which section 3 does not name")
|
|
265
|
+
for key in BACKTEST_FIELDS:
|
|
266
|
+
if key not in held:
|
|
267
|
+
raise Malformed(f"backtest.json states no {key}, which section 3 requires")
|
|
268
|
+
digits = held["digits"]
|
|
269
|
+
if not isinstance(digits, int) or isinstance(digits, bool) or digits < 0:
|
|
270
|
+
raise Malformed("backtest.json: digits is a whole number of decimal places")
|
|
271
|
+
costs = held["costs"]
|
|
272
|
+
if costs is not None and not isinstance(costs, dict):
|
|
273
|
+
raise Malformed("backtest.json: costs is a charge schedule or null")
|
|
274
|
+
window = held["range"]
|
|
275
|
+
if not isinstance(window, dict):
|
|
276
|
+
raise Malformed("backtest.json: range is an object of two bounds")
|
|
277
|
+
for bound in ("from", "to"):
|
|
278
|
+
value = window.get(bound)
|
|
279
|
+
if value is not None and (not isinstance(value, int) or isinstance(value, bool)):
|
|
280
|
+
raise Malformed(f"backtest.json: range.{bound} is a whole number of milliseconds or null")
|
|
281
|
+
return dict(held)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _declared_from(held: Any, directory: Path) -> Dict[str, Any]:
|
|
285
|
+
"""``case.json``, held to the fields section 2's table requires of every case."""
|
|
286
|
+
if not isinstance(held, dict):
|
|
287
|
+
raise Malformed("case.json is not an object")
|
|
288
|
+
identity = held.get("id")
|
|
289
|
+
if not isinstance(identity, str) or identity == "":
|
|
290
|
+
raise Malformed("case.json states no id, and section 2 says the id is the directory path")
|
|
291
|
+
walked = directory.as_posix().rstrip("/")
|
|
292
|
+
if not walked.endswith(identity):
|
|
293
|
+
raise Malformed(
|
|
294
|
+
f"case.json says its id is {identity!r} and it was read from {walked}. The id is "
|
|
295
|
+
"duplicated on purpose so that a moved directory is caught"
|
|
296
|
+
)
|
|
297
|
+
if held.get("profile") not in PROFILES:
|
|
298
|
+
raise Malformed(f"case.json names the profile {held.get('profile')!r}, which section 8 does not list")
|
|
299
|
+
version = held.get("languageVersion")
|
|
300
|
+
if not isinstance(version, int) or isinstance(version, bool):
|
|
301
|
+
raise Malformed("case.json states no languageVersion, and a case always pins one")
|
|
302
|
+
asserted = held.get("asserts")
|
|
303
|
+
if not isinstance(asserted, list) or asserted == []:
|
|
304
|
+
raise Malformed("case.json asserts nothing, and a case that asserts nothing cannot fail")
|
|
305
|
+
for channel in asserted:
|
|
306
|
+
if channel not in CHANNELS:
|
|
307
|
+
raise Malformed(f"case.json asserts {channel!r}, which section 2 does not list")
|
|
308
|
+
return dict(held)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def read_case(directory: str) -> Case:
|
|
312
|
+
"""Every file section 2's table names, from one directory, read once."""
|
|
313
|
+
path = Path(directory)
|
|
314
|
+
if not path.is_dir():
|
|
315
|
+
raise Malformed(f"{directory} is not a directory, and one case is one directory")
|
|
316
|
+
names = sorted(one.name for one in path.iterdir() if one.is_file())
|
|
317
|
+
for name in names:
|
|
318
|
+
if not is_case_file(name):
|
|
319
|
+
raise Malformed(
|
|
320
|
+
f"{name} is not a file section 2's table names, so it is not input. A case that "
|
|
321
|
+
"needs something no row covers is a case the suite cannot run until the row exists"
|
|
322
|
+
)
|
|
323
|
+
held = {name: path / name for name in names}
|
|
324
|
+
if "case.json" not in held:
|
|
325
|
+
raise Malformed("the directory holds no case.json, which section 2 requires of every case")
|
|
326
|
+
if "script.os" not in held:
|
|
327
|
+
raise Malformed("the directory holds no script.os, which section 2 requires of every case")
|
|
328
|
+
|
|
329
|
+
declared = _declared_from(_json_of(held["case.json"]), path)
|
|
330
|
+
case = Case(directory=path, declared=declared, script=_text_of(held["script.os"]))
|
|
331
|
+
|
|
332
|
+
if "bars.csv" in held:
|
|
333
|
+
case.bars = read_bars(_text_of(held["bars.csv"]))
|
|
334
|
+
if "expected.csv" in held:
|
|
335
|
+
case.expected_columns, case.expected_rows = _read_expected_csv(_text_of(held["expected.csv"]))
|
|
336
|
+
if "expected.json" in held:
|
|
337
|
+
expected = _json_of(held["expected.json"])
|
|
338
|
+
if not isinstance(expected, dict):
|
|
339
|
+
raise Malformed("expected.json is not an object of channels")
|
|
340
|
+
case.expected_json = expected
|
|
341
|
+
if "instrument.json" in held:
|
|
342
|
+
case.instrument = _instrument_from(_json_of(held["instrument.json"]))
|
|
343
|
+
case.stated_instrument = True
|
|
344
|
+
else:
|
|
345
|
+
case.instrument = dict(DEFAULT_INSTRUMENT)
|
|
346
|
+
if "settings.json" in held:
|
|
347
|
+
settings = _json_of(held["settings.json"])
|
|
348
|
+
if not isinstance(settings, dict):
|
|
349
|
+
raise Malformed("settings.json is not an object of input values by name")
|
|
350
|
+
case.settings = settings
|
|
351
|
+
if "backtest.json" in held:
|
|
352
|
+
case.backtest = _backtest_from(_json_of(held["backtest.json"]))
|
|
353
|
+
case.ticks = "ticks.csv" in held
|
|
354
|
+
if "frames.csv" in held:
|
|
355
|
+
case.frames = read_frames(_text_of(held["frames.csv"]))
|
|
356
|
+
case.secondary = tuple(name for name in names if is_secondary(name))
|
|
357
|
+
return case
|