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
openscript/civil.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Calendar arithmetic on civil dates: no zone, no instant, no clock.
|
|
2
|
+
|
|
3
|
+
A civil date is a year, a month and a day as a person writes them. Turning one
|
|
4
|
+
into an instant needs a zone and lives in ``zones.py``; everything here is
|
|
5
|
+
counting days, and the two are separated for that reason: the day number of the
|
|
6
|
+
fifteenth of March is the same fact in every zone on earth, and mixing the two
|
|
7
|
+
questions is how a calendar ends up right in one half of the year.
|
|
8
|
+
|
|
9
|
+
The day number is days since 1970-01-01, positive after it and negative before.
|
|
10
|
+
The conversion both ways is the standard proleptic Gregorian one, shifted so the
|
|
11
|
+
leap day falls at the end of an internal year that starts in March: that is what
|
|
12
|
+
removes the special case for February and makes the inverse exact rather than
|
|
13
|
+
iterative.
|
|
14
|
+
|
|
15
|
+
**Nothing here is read out of a library that answers dates.** The interpreter
|
|
16
|
+
ships one, and it holds a year to the range 1 to 9999 and raises outside it,
|
|
17
|
+
while a timestamp a script may hand ``date.year`` is any binary64 number of
|
|
18
|
+
milliseconds. An engine that raised there would stop a bar over a number the
|
|
19
|
+
language says is a number, and an engine that clamped would answer a year nobody
|
|
20
|
+
asked for, so the arithmetic is the formula and the range is the whole of it.
|
|
21
|
+
The interpreter's own calendar is used in the tests instead, as the independent
|
|
22
|
+
reference `conformance.md` section 4 asks the expected values of a numeric
|
|
23
|
+
function to agree with.
|
|
24
|
+
|
|
25
|
+
**Two instants are not a calendar field, and the boundary is stated here once.**
|
|
26
|
+
A timestamp is truncated towards zero before it is read, because that is what the
|
|
27
|
+
first engine's own date reader does with a fractional millisecond, and a
|
|
28
|
+
magnitude past the largest instant that reader holds has no fields at all rather
|
|
29
|
+
than fields read off an overflow.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
from typing import NamedTuple, Optional
|
|
34
|
+
|
|
35
|
+
#: Milliseconds in the units a reading is split into.
|
|
36
|
+
SECOND_MS = 1_000
|
|
37
|
+
MINUTE_MS = 60_000
|
|
38
|
+
HOUR_MS = 3_600_000
|
|
39
|
+
DAY_MS = 86_400_000
|
|
40
|
+
|
|
41
|
+
#: Minutes in a day, so that a wrap past midnight is named rather than written.
|
|
42
|
+
DAY_MINUTES = 1_440
|
|
43
|
+
|
|
44
|
+
#: The largest instant either engine reads.
|
|
45
|
+
#:
|
|
46
|
+
#: The first engine reads an instant through the runtime's own date object, whose
|
|
47
|
+
#: range is fixed at a hundred million days either side of the epoch; past it
|
|
48
|
+
#: there is no reading at all, and the calls of `stdlib.md` section 12.2 answer
|
|
49
|
+
#: absence. The number is here so that this engine's absence begins on the same
|
|
50
|
+
#: millisecond rather than a few days later, which is a difference no tolerance
|
|
51
|
+
#: would forgive: section 6 compares a time exactly and compares absence for
|
|
52
|
+
#: presence.
|
|
53
|
+
MAX_INSTANT = 8.64e15
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Civil(NamedTuple):
|
|
57
|
+
"""A date and a time of day as a person writes them, with no zone attached."""
|
|
58
|
+
|
|
59
|
+
year: int
|
|
60
|
+
#: 1 to 12.
|
|
61
|
+
month: int
|
|
62
|
+
#: 1 to 31.
|
|
63
|
+
day: int
|
|
64
|
+
hour: int
|
|
65
|
+
minute: int
|
|
66
|
+
second: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def day_number(year: int, month: int, day: int) -> int:
|
|
70
|
+
"""Days from 1970-01-01 to this civil date.
|
|
71
|
+
|
|
72
|
+
The era is the four hundred year block the date falls in, and it is a floor
|
|
73
|
+
division with nothing added to it. The published form of this algorithm is
|
|
74
|
+
written in a language whose division truncates towards zero, so it subtracts
|
|
75
|
+
399 from a negative year to make truncation behave like a floor; the
|
|
76
|
+
interpreter's own division already floors, and doing both is doing it twice.
|
|
77
|
+
The doubled adjustment is invisible for two thousand years either side of the
|
|
78
|
+
epoch and moves every date before the common era by one day, which is the
|
|
79
|
+
shape of mistake a test written over modern dates cannot see.
|
|
80
|
+
"""
|
|
81
|
+
shifted = year - 1 if month <= 2 else year
|
|
82
|
+
era = shifted // 400
|
|
83
|
+
year_of_era = shifted - era * 400
|
|
84
|
+
day_of_year = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
|
|
85
|
+
day_of_era = (
|
|
86
|
+
year_of_era * 365 + year_of_era // 4 - year_of_era // 100 + day_of_year
|
|
87
|
+
)
|
|
88
|
+
return era * 146097 + day_of_era - 719468
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def date_of_day(days: int) -> tuple[int, int, int]:
|
|
92
|
+
"""The civil date a day number names, the exact inverse of ``day_number``."""
|
|
93
|
+
shifted = days + 719468
|
|
94
|
+
era = shifted // 146097
|
|
95
|
+
day_of_era = shifted - era * 146097
|
|
96
|
+
year_of_era = (
|
|
97
|
+
day_of_era - day_of_era // 1460 + day_of_era // 36524 - day_of_era // 146096
|
|
98
|
+
) // 365
|
|
99
|
+
year = year_of_era + era * 400
|
|
100
|
+
day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - year_of_era // 100)
|
|
101
|
+
month_part = (5 * day_of_year + 2) // 153
|
|
102
|
+
day = day_of_year - (153 * month_part + 2) // 5 + 1
|
|
103
|
+
month = month_part + (3 if month_part < 10 else -9)
|
|
104
|
+
return (year + 1 if month <= 2 else year, month, day)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def weekday_of_day(days: int) -> int:
|
|
108
|
+
"""The day of the week, 1 for Monday through 7 for Sunday.
|
|
109
|
+
|
|
110
|
+
Monday is 1 so that a weekday test reads ``date.dayOfWeek(time) <= 5`` and a
|
|
111
|
+
trading week is a contiguous range, which `stdlib.md` section 12.2 states and
|
|
112
|
+
gives the reason for. 1970-01-01 was a Thursday, which is 4 on this
|
|
113
|
+
numbering, and the interpreter's own remainder is never negative, so a day
|
|
114
|
+
before the epoch needs no second rule.
|
|
115
|
+
"""
|
|
116
|
+
return (days + 3) % 7 + 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def day_of_year_of(year: int, month: int, day: int) -> int:
|
|
120
|
+
"""The day of the year, 1 to 366."""
|
|
121
|
+
return day_number(year, month, day) - day_number(year, 1, 1) + 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def week_of_year_of(year: int, month: int, day: int) -> int:
|
|
125
|
+
"""The week number, weeks starting Monday.
|
|
126
|
+
|
|
127
|
+
Read as the ISO 8601 week: week 1 is the week holding the year's first
|
|
128
|
+
Thursday, so a week that straddles the new year belongs to the year holding
|
|
129
|
+
most of it and no year ever has a week 0. Section 12.2 fixes the Monday start
|
|
130
|
+
and settles the rest of it for the same reason it fixes the numbering: the
|
|
131
|
+
other common reading differs by a whole week at the turn of most years, and a
|
|
132
|
+
reader has no way to tell from a chart which of the two drew it.
|
|
133
|
+
"""
|
|
134
|
+
days = day_number(year, month, day)
|
|
135
|
+
thursday = days + (4 - weekday_of_day(days))
|
|
136
|
+
owner = date_of_day(thursday)[0]
|
|
137
|
+
return (thursday - day_number(owner, 1, 1)) // 7 + 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def whole_instant(instant: float) -> Optional[int]:
|
|
141
|
+
"""A timestamp as the whole millisecond a calendar reads, or nothing.
|
|
142
|
+
|
|
143
|
+
Truncated towards zero rather than floored, which is what the first engine's
|
|
144
|
+
date reader does with a fraction, and absent past ``MAX_INSTANT``, which is
|
|
145
|
+
where that reader stops having fields to give.
|
|
146
|
+
"""
|
|
147
|
+
if not math.isfinite(instant) or abs(instant) > MAX_INSTANT:
|
|
148
|
+
return None
|
|
149
|
+
return int(instant)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def fields_at(instant: int) -> Civil:
|
|
153
|
+
"""The civil reading of a whole millisecond count, with no zone applied.
|
|
154
|
+
|
|
155
|
+
The floor division is the calendar's own: a day begins at midnight and an
|
|
156
|
+
instant before the epoch belongs to the day that holds it, not to the one
|
|
157
|
+
after it, which is what truncation towards zero would answer.
|
|
158
|
+
"""
|
|
159
|
+
days, inside = divmod(instant, DAY_MS)
|
|
160
|
+
year, month, day = date_of_day(days)
|
|
161
|
+
hour, inside = divmod(inside, HOUR_MS)
|
|
162
|
+
minute, inside = divmod(inside, MINUTE_MS)
|
|
163
|
+
return Civil(year, month, day, hour, minute, inside // SECOND_MS)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def instant_at(fields: Civil) -> int:
|
|
167
|
+
"""The millisecond count a civil reading names, with no zone applied.
|
|
168
|
+
|
|
169
|
+
**Every field carries**, so a month of 13 is January of the next year, an
|
|
170
|
+
hour of 25 is one in the morning of the next day, and a day of 0 is the last
|
|
171
|
+
of the month before. That is not this engine's licence: ``date.from`` is
|
|
172
|
+
written on the first engine over a date object whose setters carry the same
|
|
173
|
+
way, and a second engine that refused an out of range field would answer
|
|
174
|
+
absence where the first answers an instant. The carrying is linear, so it is
|
|
175
|
+
one expression rather than a normalising step.
|
|
176
|
+
"""
|
|
177
|
+
months = fields.year * 12 + (fields.month - 1)
|
|
178
|
+
year, month = divmod(months, 12)
|
|
179
|
+
days = day_number(year, month + 1, 1) + (fields.day - 1)
|
|
180
|
+
return (
|
|
181
|
+
days * DAY_MS
|
|
182
|
+
+ fields.hour * HOUR_MS
|
|
183
|
+
+ fields.minute * MINUTE_MS
|
|
184
|
+
+ fields.second * SECOND_MS
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def minutes_of(fields: Civil) -> float:
|
|
189
|
+
"""Minutes from midnight of a reading, which is what a wall clock range holds.
|
|
190
|
+
|
|
191
|
+
The seconds are in it and the milliseconds are not, because a reading is a
|
|
192
|
+
civil one: the first engine's ranges are tested against a reading with no
|
|
193
|
+
milliseconds in it, and a bound is a whole minute in both spellings of a
|
|
194
|
+
window, so the two readings fall on the same side of every bound either way.
|
|
195
|
+
"""
|
|
196
|
+
return fields.hour * 60 + fields.minute + fields.second / 60
|
openscript/contracts.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The two edges of this engine: what a host hands it, and what a library is to it.
|
|
2
|
+
|
|
3
|
+
``host-interface.md`` names six duties a host carries and
|
|
4
|
+
``compiled-program.md`` section 5.2 lists what an engine reads from one. Neither
|
|
5
|
+
is restated here. What is here is the shape those facts arrive in, so that the
|
|
6
|
+
bar cycle has one thing to read and a test has one thing to build.
|
|
7
|
+
|
|
8
|
+
**A bar and its state are two objects because they are two facts.** The prices
|
|
9
|
+
are the dataset; ``isNew``, ``isConfirmed``, ``isRealtime`` and ``updates`` are
|
|
10
|
+
facts about the delivery, which only the side that built the bar knows
|
|
11
|
+
(``host-interface.md`` section 6.2). The other four bar facts are the engine's
|
|
12
|
+
to derive from the dataset and the position in it, and it derives them rather
|
|
13
|
+
than asking, because two sources for one number can disagree and no rule would
|
|
14
|
+
say which of them wins.
|
|
15
|
+
|
|
16
|
+
**The library is a seam and not an import.** The functions of ``stdlib.md`` are
|
|
17
|
+
another stage's work and another package's; what the machine needs of them is
|
|
18
|
+
below, and it is small on purpose: a manifest to disagree with at load, and a
|
|
19
|
+
way to call one. An engine that reached into the library for anything else would
|
|
20
|
+
be an engine the library could not be replaced under, and the whole point of the
|
|
21
|
+
manifest check is that the two are separate enough to disagree.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Dict, Mapping, Optional, Protocol, Sequence
|
|
26
|
+
|
|
27
|
+
from .values import ABSENT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Bar:
|
|
32
|
+
"""One bar as a host states it, ``host-interface.md`` section 3.1.
|
|
33
|
+
|
|
34
|
+
A price may be absent: real feeds have holes, and an absent price propagates
|
|
35
|
+
rather than reading as a zero. An absent ``volume`` is not a zero volume,
|
|
36
|
+
which is that section's own three level distinction and the reason the two
|
|
37
|
+
are different values here rather than one with a default.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
time: float
|
|
41
|
+
open: Any
|
|
42
|
+
high: Any
|
|
43
|
+
low: Any
|
|
44
|
+
close: Any
|
|
45
|
+
volume: Any = ABSENT
|
|
46
|
+
oi: Any = ABSENT
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class BarState:
|
|
51
|
+
"""The four facts the host states about an execution, ``language.md`` 7.2."""
|
|
52
|
+
|
|
53
|
+
is_new: bool = True
|
|
54
|
+
is_confirmed: bool = True
|
|
55
|
+
is_realtime: bool = False
|
|
56
|
+
updates: float = 1.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class CallContext:
|
|
61
|
+
"""What a library function may be told about the moment it is running in.
|
|
62
|
+
|
|
63
|
+
Deliberately thin. Everything a function computes from the bars is already
|
|
64
|
+
in its arguments, because the compiler put it there, and everything it
|
|
65
|
+
computes from its own past is in its state region. What is left is the two
|
|
66
|
+
facts that are neither: where in the run this bar is, and what the host says
|
|
67
|
+
about the instrument.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
bar_index: int = 0
|
|
71
|
+
instrument: Mapping[str, Any] = field(default_factory=dict)
|
|
72
|
+
now: Any = ABSENT
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class LibraryEntry:
|
|
77
|
+
"""What the manifest says about one function, section 2.5's four fields."""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
arity: int
|
|
81
|
+
state: bool
|
|
82
|
+
effect: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Library(Protocol):
|
|
86
|
+
"""What the machine asks of the library, and the whole of what it asks.
|
|
87
|
+
|
|
88
|
+
``entry`` and ``describe`` are load-time: section 2.5 has the program carry
|
|
89
|
+
facts the engine already knows so that the two can be disagreed with, and
|
|
90
|
+
``describe`` is what the refusal says this engine's manifest holds instead.
|
|
91
|
+
|
|
92
|
+
``call`` is the bar. ``state`` is the call site's own region, a plain mapping
|
|
93
|
+
the engine created and owns, because section 2.11 requires a region to be
|
|
94
|
+
snapshottable by a mechanical copy without the engine knowing which function
|
|
95
|
+
it belongs to. It is ``None`` for a pure function, which is what the ``-1``
|
|
96
|
+
on a ``CALL_LIB`` means, and a function whose manifest entry says it holds no
|
|
97
|
+
state is never handed one to write into by accident.
|
|
98
|
+
|
|
99
|
+
``length_of`` is the string ceiling asked before the string exists. The
|
|
100
|
+
ceiling is the engine's to spend and the library raises nothing, so the two
|
|
101
|
+
meet here: the machine asks how long the string a call is about to build will
|
|
102
|
+
be, and refuses the call rather than the result where the answer is past the
|
|
103
|
+
ceiling. ``None`` is the honest answer for every call whose length is not
|
|
104
|
+
known until the work is done, which is all but two of them.
|
|
105
|
+
|
|
106
|
+
``builds_a_string`` is the other half, asked after. Only a call that BUILDS
|
|
107
|
+
a string is held to the ceiling, because the ceiling is on what a script
|
|
108
|
+
grows. A call that passes one through, or answers a fact the host stated,
|
|
109
|
+
hands back a string it did not make, and refusing that would refuse the
|
|
110
|
+
host its own symbol for being long. Both questions are the library's,
|
|
111
|
+
because the library is what knows which of its functions build.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def entry(self, name: str, arity: int) -> Optional[LibraryEntry]:
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
def describe(self, name: str) -> str:
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
def length_of(self, name: str, arguments: Sequence[Any]) -> Optional[int]:
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
def builds_a_string(self, name: str) -> bool:
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
def call(
|
|
127
|
+
self,
|
|
128
|
+
name: str,
|
|
129
|
+
arguments: Sequence[Any],
|
|
130
|
+
state: Optional[Dict[str, Any]],
|
|
131
|
+
context: CallContext,
|
|
132
|
+
) -> Any:
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class NoLibrary:
|
|
137
|
+
"""A library with nothing in it, which is what an engine has before one is wired.
|
|
138
|
+
|
|
139
|
+
It is not a stub standing in for the real thing: it answers every manifest
|
|
140
|
+
question with "this engine's manifest has no such function", so a program
|
|
141
|
+
that calls anything at all is refused at load with OS6004 naming the
|
|
142
|
+
function. A program that calls nothing runs exactly as it would with the
|
|
143
|
+
library present, which is what makes the machine testable on its own.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def entry(self, name: str, arity: int) -> Optional[LibraryEntry]:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def describe(self, name: str) -> str:
|
|
150
|
+
return f"no function called {name}"
|
|
151
|
+
|
|
152
|
+
def length_of(self, name: str, arguments: Sequence[Any]) -> Optional[int]:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def builds_a_string(self, name: str) -> bool:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def call(
|
|
159
|
+
self,
|
|
160
|
+
name: str,
|
|
161
|
+
arguments: Sequence[Any],
|
|
162
|
+
state: Optional[Dict[str, Any]],
|
|
163
|
+
context: CallContext,
|
|
164
|
+
) -> Any:
|
|
165
|
+
return ABSENT
|
openscript/dates.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""The calendar of `stdlib.md` section 12.2, the pattern of 12.3, and the window of 12.5.
|
|
2
|
+
|
|
3
|
+
The arithmetic is next door and raises nothing: ``civil.py`` counts days,
|
|
4
|
+
``zones.py`` says which calendar this engine can read, and ``hours.py`` is the
|
|
5
|
+
one wall clock range shared by a script's window and the instrument's own
|
|
6
|
+
session. What is here is the call sites, which is where the library's rules about
|
|
7
|
+
absence are applied, and the manifest rows a program's own library table is
|
|
8
|
+
checked against at load.
|
|
9
|
+
|
|
10
|
+
**Every call reads a timestamp in the chart's timezone unless a ``zone``
|
|
11
|
+
argument names another**, which is 12.1, and the reason it gives is worth
|
|
12
|
+
keeping in front of whoever changes this: a session study that disagreed with the
|
|
13
|
+
labels on the chart's own axis would be wrong in the way that is hardest to see.
|
|
14
|
+
The compiled program fills the default before the call arrives
|
|
15
|
+
(`compiled-program.md` section 4.10), so the zone is an argument here; a script
|
|
16
|
+
that passes ``none`` or a value that is absent on this bar falls back to the
|
|
17
|
+
chart's zone, because the alternative reads the default two ways depending on
|
|
18
|
+
whether the script wrote it out.
|
|
19
|
+
|
|
20
|
+
**A zone this engine cannot read leaves the call absent, and that is not the
|
|
21
|
+
whole of the rule.** The first engine stops the bar with OS6005 on a name its
|
|
22
|
+
database does not hold, which is what 12.1 requires. This engine holds one zone
|
|
23
|
+
(``zones.py`` says which and why), it raises nothing from a library call because
|
|
24
|
+
no call site here carries a span to point at, and the two engines therefore part
|
|
25
|
+
company on a script that names a zone: one answers a diagnostic and the other
|
|
26
|
+
answers absence. That is not papered over with a guessed offset. The caller
|
|
27
|
+
reports such a case ``unsupported`` naming the feature, which `conformance.md`
|
|
28
|
+
section 8 counts separately from a pass, and the adapter is where a case meets
|
|
29
|
+
that rule.
|
|
30
|
+
|
|
31
|
+
**Two of these are not in the table above.** ``date.add`` is planned in 12.2 and
|
|
32
|
+
``session.isHoliday`` in 12.4, so neither is in the manifest and a program
|
|
33
|
+
calling one is refused at load naming it (OS6004) rather than answered from an
|
|
34
|
+
arithmetic nobody has written down.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import math
|
|
38
|
+
from typing import Optional, Sequence
|
|
39
|
+
|
|
40
|
+
from .civil import (
|
|
41
|
+
Civil,
|
|
42
|
+
date_of_day,
|
|
43
|
+
day_number,
|
|
44
|
+
day_of_year_of,
|
|
45
|
+
week_of_year_of,
|
|
46
|
+
weekday_of_day,
|
|
47
|
+
)
|
|
48
|
+
from .hours import standing_in, window_of
|
|
49
|
+
from .library.stateless import Context, Entry
|
|
50
|
+
from .library.values import ABSENT, Value, number, result
|
|
51
|
+
from .zones import fields_in, instant_of
|
|
52
|
+
|
|
53
|
+
#: The bar fact ``session.isIn`` reads, which is the bar's own open time. It is
|
|
54
|
+
#: asked for by name like every other bar fact a stateless call reads, because
|
|
55
|
+
#: the context a call is given carries where in the run the bar is and what the
|
|
56
|
+
#: host said about the instrument, and the time of the bar being executed is
|
|
57
|
+
#: neither of those.
|
|
58
|
+
BAR_TIME = "time"
|
|
59
|
+
|
|
60
|
+
#: 12.3: the month and weekday abbreviations, English and invariant, for the same
|
|
61
|
+
#: determinism reason ``str.upper`` is: a name that followed the reader's locale
|
|
62
|
+
#: would make one chart's label depend on who opened it.
|
|
63
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
64
|
+
_WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
65
|
+
|
|
66
|
+
#: 12.3's closed set, longest first, which is the order the scan tries them in:
|
|
67
|
+
#: ``MMM`` is the month name and the ``MM`` inside it is never read a second time.
|
|
68
|
+
_PLACEHOLDERS = ("yyyy", "MMM", "EEE", "MM", "dd", "HH", "mm", "ss")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _at(arguments: Sequence[Value], index: int) -> Value:
|
|
72
|
+
"""An argument, or absence where the caller passed fewer than the arity.
|
|
73
|
+
|
|
74
|
+
A call arrives with its defaults already filled (`compiled-program.md`
|
|
75
|
+
section 4.10), so a short argument list is a program the load-time check
|
|
76
|
+
should have refused. Absence rather than a raise, for the reason the
|
|
77
|
+
library's own values module gives: nothing on this path stops a bar.
|
|
78
|
+
"""
|
|
79
|
+
return arguments[index] if index < len(arguments) else ABSENT
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _civil_at(instant: Value, zone: Value) -> Optional[Civil]:
|
|
83
|
+
"""The civil reading of a timestamp in a zone, or nothing where there is none."""
|
|
84
|
+
return None if instant is ABSENT else fields_in(instant, zone)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def field_of(instant: Value, zone: Value, which: str) -> Value:
|
|
88
|
+
"""One calendar field of an instant, read in a zone. The nine reads of 12.2."""
|
|
89
|
+
at = _civil_at(instant, zone)
|
|
90
|
+
if at is None:
|
|
91
|
+
return ABSENT
|
|
92
|
+
if which == "dayOfWeek":
|
|
93
|
+
return result(weekday_of_day(day_number(at.year, at.month, at.day)))
|
|
94
|
+
if which == "dayOfYear":
|
|
95
|
+
return result(day_of_year_of(at.year, at.month, at.day))
|
|
96
|
+
if which == "weekOfYear":
|
|
97
|
+
return result(week_of_year_of(at.year, at.month, at.day))
|
|
98
|
+
return result(getattr(at, which))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def start_of(instant: Value, zone: Value, boundary: str) -> Value:
|
|
102
|
+
"""Midnight at the start of the day, the Monday or the month that holds an instant.
|
|
103
|
+
|
|
104
|
+
The answer is built as a wall clock reading and then turned back into an
|
|
105
|
+
instant, rather than by subtracting a count of milliseconds. Subtracting is
|
|
106
|
+
wrong across a clock change, where a local day is twenty three or twenty five
|
|
107
|
+
hours long, and it is wrong in a way that shows up twice a year on one chart
|
|
108
|
+
and never on another. In the one zone this engine reads the two agree, and
|
|
109
|
+
the form that stays right is the one written, because the zone it would be
|
|
110
|
+
wrong in is the zone a host supplies a reader for.
|
|
111
|
+
"""
|
|
112
|
+
at = _civil_at(instant, zone)
|
|
113
|
+
if at is None:
|
|
114
|
+
return ABSENT
|
|
115
|
+
if boundary == "month":
|
|
116
|
+
midnight = Civil(at.year, at.month, 1, 0, 0, 0)
|
|
117
|
+
elif boundary == "day":
|
|
118
|
+
midnight = Civil(at.year, at.month, at.day, 0, 0, 0)
|
|
119
|
+
else:
|
|
120
|
+
days = day_number(at.year, at.month, at.day)
|
|
121
|
+
year, month, day = date_of_day(days - (weekday_of_day(days) - 1))
|
|
122
|
+
midnight = Civil(year, month, day, 0, 0, 0)
|
|
123
|
+
found = instant_of(midnight, zone)
|
|
124
|
+
return ABSENT if found is None else result(found)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def instant_from(arguments: Sequence[Value], zone: Value) -> Value:
|
|
128
|
+
"""``date.from``: an instant built from calendar fields in a zone.
|
|
129
|
+
|
|
130
|
+
A date is three fields and a time of day is three more, so an absent hour,
|
|
131
|
+
minute or second is midnight and an absent year, month or day is no date at
|
|
132
|
+
all. A field that is not a whole number is absent rather than rounded, for
|
|
133
|
+
the reason `stdlib.md` section 2.5 gives about a count: a day of 14.5 is a
|
|
134
|
+
bug in the script, and rounding it on the script's behalf hides the bug.
|
|
135
|
+
"""
|
|
136
|
+
if zone is ABSENT:
|
|
137
|
+
return ABSENT
|
|
138
|
+
held = []
|
|
139
|
+
for at in range(6):
|
|
140
|
+
given = number(arguments[at]) if at < len(arguments) else None
|
|
141
|
+
if given is None:
|
|
142
|
+
if at < 3:
|
|
143
|
+
return ABSENT
|
|
144
|
+
given = 0.0
|
|
145
|
+
if not math.isfinite(given) or given != int(given):
|
|
146
|
+
return ABSENT
|
|
147
|
+
held.append(int(given))
|
|
148
|
+
found = instant_of(Civil(*held), zone)
|
|
149
|
+
return ABSENT if found is None else result(found)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def same_day(first: Value, second: Value, zone: Value) -> Value:
|
|
153
|
+
"""``date.isSameDay``: whether two timestamps fall on one calendar day."""
|
|
154
|
+
left = _civil_at(first, zone)
|
|
155
|
+
right = _civil_at(second, zone)
|
|
156
|
+
if left is None or right is None:
|
|
157
|
+
return ABSENT
|
|
158
|
+
return (left.year, left.month, left.day) == (right.year, right.month, right.day)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _padded(value: int, width: int) -> str:
|
|
162
|
+
"""A field as its digits, zero filled, with the sign in front of the filling."""
|
|
163
|
+
digits = str(abs(int(value)))
|
|
164
|
+
filled = digits if len(digits) >= width else "0" * (width - len(digits)) + digits
|
|
165
|
+
return f"-{filled}" if value < 0 else filled
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _substitution(token: str, at: Civil) -> str:
|
|
169
|
+
if token == "yyyy":
|
|
170
|
+
return _padded(at.year, 4)
|
|
171
|
+
if token == "MMM":
|
|
172
|
+
return _MONTHS[at.month - 1] if 1 <= at.month <= 12 else ""
|
|
173
|
+
if token == "EEE":
|
|
174
|
+
return _WEEKDAYS[weekday_of_day(day_number(at.year, at.month, at.day)) - 1]
|
|
175
|
+
if token == "MM":
|
|
176
|
+
return _padded(at.month, 2)
|
|
177
|
+
if token == "dd":
|
|
178
|
+
return _padded(at.day, 2)
|
|
179
|
+
if token == "HH":
|
|
180
|
+
return _padded(at.hour, 2)
|
|
181
|
+
if token == "mm":
|
|
182
|
+
return _padded(at.minute, 2)
|
|
183
|
+
return _padded(at.second, 2)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def rendered(instant: Value, pattern: Value, zone: Value) -> Value:
|
|
187
|
+
"""``date.format``: 12.3's closed placeholder set, and every other character copied.
|
|
188
|
+
|
|
189
|
+
The set is small and closed so that two engines cannot differ about what a
|
|
190
|
+
pattern means, and a character that looks like part of a placeholder is
|
|
191
|
+
copied through as itself: the scan takes the longest placeholder starting at
|
|
192
|
+
each position and moves past it.
|
|
193
|
+
"""
|
|
194
|
+
at = _civil_at(instant, zone)
|
|
195
|
+
if at is None or not isinstance(pattern, str):
|
|
196
|
+
return ABSENT
|
|
197
|
+
out = []
|
|
198
|
+
position = 0
|
|
199
|
+
while position < len(pattern):
|
|
200
|
+
token = next(
|
|
201
|
+
(one for one in _PLACEHOLDERS if pattern.startswith(one, position)), None
|
|
202
|
+
)
|
|
203
|
+
if token is None:
|
|
204
|
+
out.append(pattern[position])
|
|
205
|
+
position += 1
|
|
206
|
+
continue
|
|
207
|
+
out.append(_substitution(token, at))
|
|
208
|
+
position += len(token)
|
|
209
|
+
return "".join(out)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def holds(spec: Value, instant: Value, zone: Value) -> Value:
|
|
213
|
+
"""``session.isIn``: whether this bar's reading falls in the window a script wrote.
|
|
214
|
+
|
|
215
|
+
The window is the script's own and the instrument's session is the host's, so
|
|
216
|
+
this reads 12.5's spelling and the record reads 4.3's, and both reach the one
|
|
217
|
+
range arithmetic in ``hours.py``.
|
|
218
|
+
"""
|
|
219
|
+
hours = window_of(spec)
|
|
220
|
+
at = _civil_at(instant, zone)
|
|
221
|
+
if hours is None or at is None:
|
|
222
|
+
return ABSENT
|
|
223
|
+
return standing_in(hours, at).inside
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _zone_at(ctx: Context, arguments: Sequence[Value], index: int) -> Value:
|
|
227
|
+
"""The zone a call reads in: the one it was given, or the chart's.
|
|
228
|
+
|
|
229
|
+
A host that states no timezone leaves every call here absent rather than
|
|
230
|
+
answered in a zone nobody chose, which is 12.1 read the only way it can be:
|
|
231
|
+
the default is the chart's axis, and a chart with no axis to read has none.
|
|
232
|
+
"""
|
|
233
|
+
given = _at(arguments, index)
|
|
234
|
+
if isinstance(given, str):
|
|
235
|
+
return given
|
|
236
|
+
return ctx.host("timezone")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _read(name: str, which: str) -> Entry:
|
|
240
|
+
"""One of the nine field reads, which take a timestamp and a zone."""
|
|
241
|
+
return Entry(
|
|
242
|
+
f"date.{name}",
|
|
243
|
+
2,
|
|
244
|
+
lambda ctx, args: field_of(_at(args, 0), _zone_at(ctx, args, 1), which),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _boundary(name: str, which: str) -> Entry:
|
|
249
|
+
"""One of the three boundaries a timestamp rounds back to."""
|
|
250
|
+
return Entry(
|
|
251
|
+
f"date.{name}",
|
|
252
|
+
2,
|
|
253
|
+
lambda ctx, args: start_of(_at(args, 0), _zone_at(ctx, args, 1), which),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
#: Section 12.2's table and 12.5's one call, as the manifest rows section 2.5 of
|
|
258
|
+
#: `compiled-program.md` holds a program's own library table to. Every one of
|
|
259
|
+
#: them holds no state and has no effect: the answer is the arguments, the
|
|
260
|
+
#: chart's zone and, for the window, the bar the engine is executing.
|
|
261
|
+
ENTRIES: tuple[Entry, ...] = (
|
|
262
|
+
_read("year", "year"),
|
|
263
|
+
_read("month", "month"),
|
|
264
|
+
_read("day", "day"),
|
|
265
|
+
_read("dayOfWeek", "dayOfWeek"),
|
|
266
|
+
_read("dayOfYear", "dayOfYear"),
|
|
267
|
+
_read("hour", "hour"),
|
|
268
|
+
_read("minute", "minute"),
|
|
269
|
+
_read("second", "second"),
|
|
270
|
+
_read("weekOfYear", "weekOfYear"),
|
|
271
|
+
_boundary("startOfDay", "day"),
|
|
272
|
+
_boundary("startOfWeek", "week"),
|
|
273
|
+
_boundary("startOfMonth", "month"),
|
|
274
|
+
Entry("date.from", 7, lambda ctx, args: instant_from(args, _zone_at(ctx, args, 6))),
|
|
275
|
+
Entry(
|
|
276
|
+
"date.isSameDay",
|
|
277
|
+
3,
|
|
278
|
+
lambda ctx, args: same_day(_at(args, 0), _at(args, 1), _zone_at(ctx, args, 2)),
|
|
279
|
+
),
|
|
280
|
+
Entry(
|
|
281
|
+
"date.format",
|
|
282
|
+
3,
|
|
283
|
+
lambda ctx, args: rendered(_at(args, 0), _at(args, 1), _zone_at(ctx, args, 2)),
|
|
284
|
+
),
|
|
285
|
+
Entry(
|
|
286
|
+
"session.isIn",
|
|
287
|
+
2,
|
|
288
|
+
lambda ctx, args: holds(_at(args, 0), ctx.bar(BAR_TIME), _zone_at(ctx, args, 1)),
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
#: Every name this module answers, which is what a caller asks when the question
|
|
294
|
+
#: is about the calendar rather than about one call: a case whose record names a
|
|
295
|
+
#: zone this engine cannot read, and whose program reaches any of these, is a
|
|
296
|
+
#: case answered under the wrong calendar unless the caller declines it.
|
|
297
|
+
NAMES: frozenset = frozenset(entry.name for entry in ENTRIES)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def table() -> dict[tuple[str, int], Entry]:
|
|
301
|
+
"""The entries by name and arity, which is how a manifest is looked up."""
|
|
302
|
+
return {(entry.name, entry.arity): entry for entry in ENTRIES}
|