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,168 @@
|
|
|
1
|
+
"""The session facts an engine derives, ``host-interface.md`` 4.3 and ``stdlib.md`` 12.4.
|
|
2
|
+
|
|
3
|
+
**A session is the instrument's, never a window a script invents.** The record
|
|
4
|
+
states a wall clock range, the days it runs on and the zone it is read in, and
|
|
5
|
+
the engine derives the per-bar facts from those and the bar's own time: "an
|
|
6
|
+
engine derives them and never asks a host for them: the facts a host states about
|
|
7
|
+
an execution are the four of ``language.md`` section 7.2 and no others".
|
|
8
|
+
|
|
9
|
+
**A record that states no session leaves every fact absent**, which is 4.1's own
|
|
10
|
+
rule and is exactly what an instrument with no schedule looks like. A record that
|
|
11
|
+
states one badly is refused instead (OS6012), because the difference between a
|
|
12
|
+
fact nobody stated and a fact stated wrongly is the one thing reading the record
|
|
13
|
+
can tell you.
|
|
14
|
+
|
|
15
|
+
**The window arithmetic is not here.** A range of wall clock hours is two things
|
|
16
|
+
in this engine, the record's session and the window a script writes for
|
|
17
|
+
``session.isIn``, and ``hours.py`` is the one arithmetic both of them reach: a
|
|
18
|
+
second copy would drift over midnight, or over which day a list of days is read
|
|
19
|
+
against, on the dataset nobody tests. What is here is the record: its three
|
|
20
|
+
refusals, and the two facts derived per bar.
|
|
21
|
+
|
|
22
|
+
**Both derived facts are computed here and one of them is in the manifest.**
|
|
23
|
+
``session.isFirstBar`` looks backwards, which an engine can answer on the bar it
|
|
24
|
+
is executing. ``session.isLastBar`` is "true on the last bar of the schedule even
|
|
25
|
+
if trading stopped early", so it is a bar slot measured against the scheduled
|
|
26
|
+
close and it needs the interval, which ``intervals.py`` now reads;
|
|
27
|
+
``last_bars`` below is that reading. It is not in ``SESSION_FACTS`` yet, because
|
|
28
|
+
the seam that answers a session fact carries one bar fact to answer it with, and
|
|
29
|
+
a name added to that tuple without a second fact beside it would answer
|
|
30
|
+
``isLastBar`` with ``isFirstBar``'s value, which is worse than the refusal a
|
|
31
|
+
program gets today (OS6004, naming the function).
|
|
32
|
+
|
|
33
|
+
**The zone is read as the one this engine can read.** A calendar in any other
|
|
34
|
+
zone is a reader the host supplies, so a case whose record names another zone is
|
|
35
|
+
named ``unsupported`` by the caller rather than answered under the wrong
|
|
36
|
+
calendar. ``zones.py`` states which zone that is and why it is one.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from typing import Any, Mapping, Optional, Sequence, Tuple
|
|
40
|
+
|
|
41
|
+
from ..hours import Hours, clock_minutes, days_numbered, standing_in
|
|
42
|
+
from ..intervals import bar_minutes_of
|
|
43
|
+
from ..values import ABSENT
|
|
44
|
+
from ..zones import READABLE as READABLE_ZONE, fields_in
|
|
45
|
+
from .spellings import Malformed
|
|
46
|
+
|
|
47
|
+
#: ``stdlib.md`` 12.4: the entries of the namespace this engine serves.
|
|
48
|
+
SESSION_FACTS: Tuple[str, ...] = ("session.isFirstBar",)
|
|
49
|
+
|
|
50
|
+
#: The bar fact both readers of a session boundary take it from: the namespace
|
|
51
|
+
#: entry above, and ``vwap``, which restarts where the session opened.
|
|
52
|
+
SESSION_FIRST = "isSessionFirst"
|
|
53
|
+
|
|
54
|
+
#: The record's session is a range of wall clock hours and nothing else, which is
|
|
55
|
+
#: what lets it and a script's own window be one arithmetic.
|
|
56
|
+
Session = Hours
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _minutes(text: Any, field: str) -> int:
|
|
60
|
+
"""``"HH:MM"``, in the one spelling 4.3 fixes, or the refusal it names.
|
|
61
|
+
|
|
62
|
+
``"9:00"`` is the spelling a host writes first and it is not a time here, so
|
|
63
|
+
it is refused rather than read: a record stating a window in a spelling the
|
|
64
|
+
page does not have is a record whose author believes they stated a session.
|
|
65
|
+
"""
|
|
66
|
+
held = clock_minutes(text)
|
|
67
|
+
if held is None:
|
|
68
|
+
raise Malformed(
|
|
69
|
+
f"instrument.json: the session's {field} is {text!r}, and host-interface.md 4.3 "
|
|
70
|
+
'spells a wall clock time "HH:MM", where the latest time it has is "24:00", '
|
|
71
|
+
"midnight at the end of the day"
|
|
72
|
+
)
|
|
73
|
+
return held
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def session_from(instrument: Mapping[str, Any]) -> Optional[Session]:
|
|
77
|
+
"""The record's session, or nothing where it states none.
|
|
78
|
+
|
|
79
|
+
The three refusals are 4.3's own table, in its order: a window with no zone
|
|
80
|
+
to read it in, a bound that is not a wall clock time, and a day outside the
|
|
81
|
+
numbering ``date.dayOfWeek`` uses.
|
|
82
|
+
"""
|
|
83
|
+
held = instrument.get("session")
|
|
84
|
+
if held is None:
|
|
85
|
+
return None
|
|
86
|
+
if not isinstance(held, dict):
|
|
87
|
+
raise Malformed("instrument.json: the session is not a window of a start, an end and days")
|
|
88
|
+
if not isinstance(instrument.get("timezone"), str):
|
|
89
|
+
raise Malformed(
|
|
90
|
+
"instrument.json states a session and no timezone, and host-interface.md 4.3 refuses "
|
|
91
|
+
"that record at load: a window is wall clock and a wall clock is read in a zone"
|
|
92
|
+
)
|
|
93
|
+
days = held.get("days")
|
|
94
|
+
if not isinstance(days, list) or not days_numbered(days):
|
|
95
|
+
raise Malformed(
|
|
96
|
+
f"instrument.json: the session states the days {days!r}, and 4.3 numbers Monday as 1 "
|
|
97
|
+
"through Sunday as 7, with an empty list not one of them"
|
|
98
|
+
)
|
|
99
|
+
return Session(_minutes(held.get("start"), "start"), _minutes(held.get("end"), "end"), tuple(days))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def opening_day(time: int, session: Session) -> Optional[int]:
|
|
103
|
+
"""Which session a bar belongs to, as the day it opened on, or nothing.
|
|
104
|
+
|
|
105
|
+
The day is an identity and not a date: two bars of one session share it, and
|
|
106
|
+
the bar that does not share it with the bar before it is the bar that opened
|
|
107
|
+
a session. A bar outside the window belongs to no session, which is a false
|
|
108
|
+
reading of every fact here rather than an absent one.
|
|
109
|
+
"""
|
|
110
|
+
standing = _standing(time, session)
|
|
111
|
+
return None if standing is None or not standing.inside else standing.opening_day
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def first_bars(times: Sequence[int], session: Optional[Session]) -> Tuple[Any, ...]:
|
|
115
|
+
"""Whether each bar opens its session, absent throughout where there is none.
|
|
116
|
+
|
|
117
|
+
Read forwards and never backwards: a bar opens a session when it is in one
|
|
118
|
+
and the last bar that was in one was in a different one. A bar outside the
|
|
119
|
+
window opens nothing and leaves the answer for the next bar alone, so a
|
|
120
|
+
session with a gap in the middle of it is one session and not two.
|
|
121
|
+
"""
|
|
122
|
+
if session is None:
|
|
123
|
+
return tuple(ABSENT for _ in times)
|
|
124
|
+
found = []
|
|
125
|
+
previous: Optional[int] = None
|
|
126
|
+
for time in times:
|
|
127
|
+
opened = opening_day(time, session)
|
|
128
|
+
found.append(opened is not None and opened != previous)
|
|
129
|
+
if opened is not None:
|
|
130
|
+
previous = opened
|
|
131
|
+
return tuple(found)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def last_bars(
|
|
135
|
+
times: Sequence[int], session: Optional[Session], interval: Any
|
|
136
|
+
) -> Tuple[Any, ...]:
|
|
137
|
+
"""Whether each bar is the last of its session's schedule, or absent.
|
|
138
|
+
|
|
139
|
+
The schedule and not the trading: a bar is the last one when the time left
|
|
140
|
+
until the scheduled close is no more than one bar, so it is true on the bar
|
|
141
|
+
that covers the close even if nothing traded in it and even if the feed
|
|
142
|
+
stopped an hour earlier. That is the whole reason 4.3 says a strategy which
|
|
143
|
+
must be flat by the close acts on this rather than on the appearance of a new
|
|
144
|
+
bar, which arrives too late.
|
|
145
|
+
|
|
146
|
+
Absent where there is no session, and absent where the host stated no
|
|
147
|
+
interval this engine can read: without a bar's length there is no slot to
|
|
148
|
+
measure against the close, and answering from the spacing of the bars would
|
|
149
|
+
be an engine promising something the specification does not, which is that
|
|
150
|
+
the bars are evenly spaced.
|
|
151
|
+
"""
|
|
152
|
+
minutes = bar_minutes_of(interval)
|
|
153
|
+
if session is None or minutes is None:
|
|
154
|
+
return tuple(ABSENT for _ in times)
|
|
155
|
+
found = []
|
|
156
|
+
for time in times:
|
|
157
|
+
standing = _standing(time, session)
|
|
158
|
+
if standing is None:
|
|
159
|
+
found.append(ABSENT)
|
|
160
|
+
else:
|
|
161
|
+
found.append(standing.inside and standing.remaining <= minutes)
|
|
162
|
+
return tuple(found)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _standing(time: Any, session: Session) -> Optional[Any]:
|
|
166
|
+
"""Where one bar's reading sits in the window, in the zone this engine reads."""
|
|
167
|
+
at = fields_in(time, READABLE_ZONE)
|
|
168
|
+
return None if at is None else standing_in(session, at)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""How a value is written in a case file and in a report, and how one is read back.
|
|
2
|
+
|
|
3
|
+
``conformance.md`` sections 3 and 4 fix both directions and this module is the
|
|
4
|
+
whole of them, so that a reader of a case file and a writer of a report cannot
|
|
5
|
+
part company over a spelling.
|
|
6
|
+
|
|
7
|
+
**A number is read strictly.** The page says a price is "written in the shortest
|
|
8
|
+
form that reads back to the exact binary64 value intended", which is a decimal
|
|
9
|
+
and nothing else. The interpreter's own reader is wider than that: it takes an
|
|
10
|
+
infinity by name, a not-a-number, a hexadecimal fraction, surrounding space and
|
|
11
|
+
an underscore between digits, and each of those would enter the run as a value
|
|
12
|
+
``language.md`` section 5.1 says never appears. So the text is held to the
|
|
13
|
+
grammar first and converted after, and a case carrying one of those spellings is
|
|
14
|
+
malformed rather than quietly run.
|
|
15
|
+
|
|
16
|
+
**A number is written by the language's own rule.** ``language.md`` section 5.5
|
|
17
|
+
decides when the positional form is used and how an exponent is spelled, and
|
|
18
|
+
``canonical.py`` next door already implements it for the compiled program's
|
|
19
|
+
encoding. A second rule here would be the one that drifted, and the difference
|
|
20
|
+
would show up as two engines reporting one value two ways in a document a person
|
|
21
|
+
reads to decide which of them is wrong.
|
|
22
|
+
|
|
23
|
+
**A colour is written after the alpha has become a byte**, which is
|
|
24
|
+
``compiled-program.md`` section 3.1's one way conversion and the reason section 6
|
|
25
|
+
can compare four integers rather than a float. The two spellings of a colour this
|
|
26
|
+
package holds are both accepted here; the note in ``running.py`` says why there
|
|
27
|
+
are two.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from typing import Any, Optional
|
|
32
|
+
|
|
33
|
+
from ..canonical import canonical_number
|
|
34
|
+
from ..library.colour import Colour as LibraryColour, hex_byte
|
|
35
|
+
from ..library.rounding import round_half_away
|
|
36
|
+
from ..values import Colour as MachineColour
|
|
37
|
+
from .page import ABSENT_TEXT
|
|
38
|
+
|
|
39
|
+
#: A decimal, and nothing else: an optional sign, digits, an optional fraction
|
|
40
|
+
#: and an optional exponent. Nothing else the interpreter's reader would accept.
|
|
41
|
+
_DECIMAL = re.compile(r"^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$")
|
|
42
|
+
|
|
43
|
+
#: A whole number of milliseconds, which is what every time in a case file is.
|
|
44
|
+
_WHOLE = re.compile(r"^[+-]?[0-9]+$")
|
|
45
|
+
|
|
46
|
+
#: The eight hexadecimal digits a colour is compared as, with the leading hash.
|
|
47
|
+
_COLOUR = re.compile(r"^#[0-9a-f]{8}$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Malformed(Exception):
|
|
51
|
+
"""A case file that cannot be read, which section 9 files under ``error``.
|
|
52
|
+
|
|
53
|
+
Carries one sentence naming the file, the row and what was wrong with it. A
|
|
54
|
+
case is input to every engine that meets it, so the cost of a directory this
|
|
55
|
+
one cannot read lands on whoever wrote the directory, and the sentence is
|
|
56
|
+
what tells them which of the two it is.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_number(text: str, where: str) -> float:
|
|
61
|
+
"""A decimal as the binary64 value it names, or a refusal naming where."""
|
|
62
|
+
if not _DECIMAL.match(text):
|
|
63
|
+
raise Malformed(
|
|
64
|
+
f"{where}: {text!r} is not a decimal number. A case file writes a number in the "
|
|
65
|
+
"shortest form that reads back to the value intended, and every other spelling a "
|
|
66
|
+
"reader might accept, an infinity, a not-a-number or a digit separator, is a value "
|
|
67
|
+
"this language holds none of"
|
|
68
|
+
)
|
|
69
|
+
value = float(text)
|
|
70
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
71
|
+
raise Malformed(f"{where}: {text!r} is outside the finite range of a binary64 number")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_whole(text: str, where: str) -> int:
|
|
76
|
+
"""A whole number, which is what a time and a quantity in a case file are."""
|
|
77
|
+
if not _WHOLE.match(text):
|
|
78
|
+
raise Malformed(f"{where}: {text!r} is not a whole number")
|
|
79
|
+
return int(text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_bool(text: str, where: str) -> bool:
|
|
83
|
+
if text == "true":
|
|
84
|
+
return True
|
|
85
|
+
if text == "false":
|
|
86
|
+
return False
|
|
87
|
+
raise Malformed(f"{where}: {text!r} is neither true nor false")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_colour(text: str, where: str) -> str:
|
|
91
|
+
"""A colour, kept as the eight digit spelling it is compared as.
|
|
92
|
+
|
|
93
|
+
It is not turned back into channels and an alpha. Section 3.1 says the
|
|
94
|
+
conversion to a byte is one way and not a round trip, so a colour read back
|
|
95
|
+
into the machine's own form and written out again would not always be the
|
|
96
|
+
text the file holds, and the text is what section 6 compares.
|
|
97
|
+
"""
|
|
98
|
+
if not _COLOUR.match(text):
|
|
99
|
+
raise Malformed(
|
|
100
|
+
f"{where}: {text!r} is not a colour. A colour is written #rrggbbaa, always eight "
|
|
101
|
+
"hexadecimal digits and always lower case"
|
|
102
|
+
)
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def colour_text(value: Any) -> str:
|
|
107
|
+
"""A colour the machine holds, in the one spelling a case file writes.
|
|
108
|
+
|
|
109
|
+
The alpha becomes a byte here and nowhere else: ``round(alpha * 255)`` with
|
|
110
|
+
the language's own rounding, halves away from zero, which is section 3.1's
|
|
111
|
+
rule and the rounding ``stdlib.md`` section 11.2 gives every colour call.
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(value, MachineColour):
|
|
114
|
+
red, green, blue, alpha = value.red, value.green, value.blue, value.alpha
|
|
115
|
+
elif isinstance(value, LibraryColour):
|
|
116
|
+
red, green, blue, alpha = value.r, value.g, value.b, value.a
|
|
117
|
+
else:
|
|
118
|
+
raise Malformed(f"{value!r} is not a colour")
|
|
119
|
+
channels = "".join(hex_byte(one) for one in (red, green, blue))
|
|
120
|
+
return f"#{channels}{hex_byte(round_half_away(alpha * 255.0))}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def written(value: Any) -> str:
|
|
124
|
+
"""A value as a report writes it, which is as an expected file writes it.
|
|
125
|
+
|
|
126
|
+
Section 9: "Numbers in a report are written as the shortest round-tripping
|
|
127
|
+
decimal, exactly as in an expected file, so a difference in the last bit is
|
|
128
|
+
visible in the report rather than rounded away by the reporting."
|
|
129
|
+
"""
|
|
130
|
+
if value is None:
|
|
131
|
+
return ABSENT_TEXT
|
|
132
|
+
if isinstance(value, bool):
|
|
133
|
+
return "true" if value else "false"
|
|
134
|
+
if isinstance(value, (int, float)):
|
|
135
|
+
return canonical_number(float(value))
|
|
136
|
+
if isinstance(value, str):
|
|
137
|
+
return value
|
|
138
|
+
if isinstance(value, (MachineColour, LibraryColour)):
|
|
139
|
+
return colour_text(value)
|
|
140
|
+
return repr(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def as_reported(value: Any) -> Any:
|
|
144
|
+
"""One value of an answered channel, in the JSON shapes section 4 gives.
|
|
145
|
+
|
|
146
|
+
Absence is null, a number is a number, a bool is a bool, a string is itself,
|
|
147
|
+
and a colour is its eight digit spelling, because that is what section 6
|
|
148
|
+
compares a colour as and a JSON document has no colour of its own.
|
|
149
|
+
"""
|
|
150
|
+
if value is None or isinstance(value, (bool, str)):
|
|
151
|
+
return value
|
|
152
|
+
if isinstance(value, (int, float)):
|
|
153
|
+
return float(value)
|
|
154
|
+
if isinstance(value, (MachineColour, LibraryColour)):
|
|
155
|
+
return colour_text(value)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_cell(text: str, kind: str, where: str) -> Optional[Any]:
|
|
160
|
+
"""One field of ``expected.csv``, read as the channel's own type.
|
|
161
|
+
|
|
162
|
+
The type comes from the compiled program's channel table
|
|
163
|
+
(``compiled-program.md`` section 2.7) rather than from the shape of the
|
|
164
|
+
text, so a string channel holding ``none`` is a channel that produced
|
|
165
|
+
nothing and not the four letters.
|
|
166
|
+
|
|
167
|
+
An empty field and ``none`` are both absence here, and that is a narrowing
|
|
168
|
+
of section 4 rather than a reading of it: the page distinguishes "this
|
|
169
|
+
channel produced nothing on this bar" from an absent number, and a channel
|
|
170
|
+
on this machine has one state for both (section 2.7: a channel's value is
|
|
171
|
+
absent unless something wrote it). An engine that answered two different
|
|
172
|
+
values would be inventing a distinction it cannot observe.
|
|
173
|
+
"""
|
|
174
|
+
if text == "" or text == ABSENT_TEXT:
|
|
175
|
+
return None
|
|
176
|
+
if kind == "number":
|
|
177
|
+
return read_number(text, where)
|
|
178
|
+
if kind == "bool":
|
|
179
|
+
return read_bool(text, where)
|
|
180
|
+
if kind == "color":
|
|
181
|
+
return read_colour(text, where)
|
|
182
|
+
if kind == "string":
|
|
183
|
+
return text
|
|
184
|
+
raise Malformed(f"{where}: {kind!r} is not a channel type this adapter reads")
|
openscript/bars.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Step 4 of the bar cycle: the host's bar written into every ``"bar"`` register.
|
|
2
|
+
|
|
3
|
+
The derived price fields are written out as expressions rather than as formulas
|
|
4
|
+
because **their order of operations is part of the contract** (section 2.10):
|
|
5
|
+
``hlc3`` adds high to low, adds close to that, then divides. A different
|
|
6
|
+
association gives a different last bit, and a study that matches a reference
|
|
7
|
+
implementation on one engine and not on another is exactly the failure this
|
|
8
|
+
project exists to prevent.
|
|
9
|
+
|
|
10
|
+
**An absent volume rather than a zero is deliberate.** A script that sizes
|
|
11
|
+
something by volume has to be able to tell "no trades" from "nobody told me",
|
|
12
|
+
which is ``host-interface.md`` section 3.3 and three levels of the same
|
|
13
|
+
distinction.
|
|
14
|
+
|
|
15
|
+
Four of the eight bar facts are the host's and four are the engine's, and a fact
|
|
16
|
+
the engine can derive is never also stated by the host, because two sources for
|
|
17
|
+
one number can disagree and no rule would say which of them wins.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Tuple
|
|
22
|
+
|
|
23
|
+
from .contracts import Bar, BarState
|
|
24
|
+
from .values import ABSENT, finite, is_number
|
|
25
|
+
|
|
26
|
+
#: The bar fields section 2.10 names.
|
|
27
|
+
#:
|
|
28
|
+
#: ``oi`` is here and is the one field the section's own table does not list. It
|
|
29
|
+
#: is a field of a bar everywhere else: ``host-interface.md`` section 3.1 states
|
|
30
|
+
#: it, and section 2.16.2 folds it into a bucket bar by taking the last rather
|
|
31
|
+
#: than the sum. A register kind the compiler can emit and this engine could not
|
|
32
|
+
#: fill would be absent on every bar with nothing saying why, so it is filled and
|
|
33
|
+
#: the omission is reported rather than resolved here.
|
|
34
|
+
BAR_FIELDS: Tuple[str, ...] = (
|
|
35
|
+
"open",
|
|
36
|
+
"high",
|
|
37
|
+
"low",
|
|
38
|
+
"close",
|
|
39
|
+
"volume",
|
|
40
|
+
"time",
|
|
41
|
+
"hl2",
|
|
42
|
+
"hlc3",
|
|
43
|
+
"ohlc4",
|
|
44
|
+
"hlcc4",
|
|
45
|
+
"oi",
|
|
46
|
+
"bar.index",
|
|
47
|
+
"bar.count",
|
|
48
|
+
"bar.isFirst",
|
|
49
|
+
"bar.isLast",
|
|
50
|
+
"bar.isConfirmed",
|
|
51
|
+
"bar.isRealtime",
|
|
52
|
+
"bar.isNew",
|
|
53
|
+
"bar.updates",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_bar_field(name: str) -> bool:
|
|
58
|
+
return name in BAR_FIELDS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BarFacts:
|
|
63
|
+
"""The eight facts of ``language.md`` section 7.2, for the bar being executed.
|
|
64
|
+
|
|
65
|
+
``index`` is a position in the supplied data and not a universal address:
|
|
66
|
+
loading more history shifts every index, which is why a script that has to
|
|
67
|
+
remember a bar stores ``time`` instead.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
index: int
|
|
71
|
+
count: int
|
|
72
|
+
is_first: bool
|
|
73
|
+
is_last: bool
|
|
74
|
+
is_confirmed: bool
|
|
75
|
+
is_realtime: bool
|
|
76
|
+
is_new: bool
|
|
77
|
+
updates: float
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def facts_for(index: int, supplied: int, state: BarState) -> BarFacts:
|
|
81
|
+
return BarFacts(
|
|
82
|
+
index=index,
|
|
83
|
+
count=index + 1,
|
|
84
|
+
is_first=index == 0,
|
|
85
|
+
# True when this is the greatest index the host has supplied. A live
|
|
86
|
+
# chart supplies bars one at a time, so its newest bar is always the
|
|
87
|
+
# last; a run over a whole dataset supplies them together, so only its
|
|
88
|
+
# final bar is.
|
|
89
|
+
is_last=index == supplied - 1,
|
|
90
|
+
is_confirmed=state.is_confirmed,
|
|
91
|
+
is_realtime=state.is_realtime,
|
|
92
|
+
is_new=state.is_new,
|
|
93
|
+
updates=float(state.updates),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _held(value: Any) -> Any:
|
|
98
|
+
return float(value) if is_number(value) else ABSENT
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bar_field(field: str, bar: Bar, facts: BarFacts) -> Any:
|
|
102
|
+
"""One bar field's value, by the definitions of section 2.10."""
|
|
103
|
+
if field == "open":
|
|
104
|
+
return _held(bar.open)
|
|
105
|
+
if field == "high":
|
|
106
|
+
return _held(bar.high)
|
|
107
|
+
if field == "low":
|
|
108
|
+
return _held(bar.low)
|
|
109
|
+
if field == "close":
|
|
110
|
+
return _held(bar.close)
|
|
111
|
+
if field == "volume":
|
|
112
|
+
return _held(bar.volume)
|
|
113
|
+
if field == "oi":
|
|
114
|
+
return _held(bar.oi)
|
|
115
|
+
if field == "time":
|
|
116
|
+
return _held(bar.time)
|
|
117
|
+
|
|
118
|
+
high, low, close, opened = (
|
|
119
|
+
_held(bar.high),
|
|
120
|
+
_held(bar.low),
|
|
121
|
+
_held(bar.close),
|
|
122
|
+
_held(bar.open),
|
|
123
|
+
)
|
|
124
|
+
if field == "hl2":
|
|
125
|
+
if high is ABSENT or low is ABSENT:
|
|
126
|
+
return ABSENT
|
|
127
|
+
return finite((high + low) / 2)
|
|
128
|
+
if field == "hlc3":
|
|
129
|
+
if high is ABSENT or low is ABSENT or close is ABSENT:
|
|
130
|
+
return ABSENT
|
|
131
|
+
return finite((high + low + close) / 3)
|
|
132
|
+
if field == "ohlc4":
|
|
133
|
+
if opened is ABSENT or high is ABSENT or low is ABSENT or close is ABSENT:
|
|
134
|
+
return ABSENT
|
|
135
|
+
return finite((opened + high + low + close) / 4)
|
|
136
|
+
if field == "hlcc4":
|
|
137
|
+
if high is ABSENT or low is ABSENT or close is ABSENT:
|
|
138
|
+
return ABSENT
|
|
139
|
+
return finite((high + low + close + close) / 4)
|
|
140
|
+
|
|
141
|
+
if field == "bar.index":
|
|
142
|
+
return float(facts.index)
|
|
143
|
+
if field == "bar.count":
|
|
144
|
+
return float(facts.count)
|
|
145
|
+
if field == "bar.isFirst":
|
|
146
|
+
return facts.is_first
|
|
147
|
+
if field == "bar.isLast":
|
|
148
|
+
return facts.is_last
|
|
149
|
+
if field == "bar.isConfirmed":
|
|
150
|
+
return facts.is_confirmed
|
|
151
|
+
if field == "bar.isRealtime":
|
|
152
|
+
return facts.is_realtime
|
|
153
|
+
if field == "bar.isNew":
|
|
154
|
+
return facts.is_new
|
|
155
|
+
if field == "bar.updates":
|
|
156
|
+
return float(facts.updates)
|
|
157
|
+
return ABSENT
|