openscript 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. openscript/__init__.py +40 -0
  2. openscript/__main__.py +62 -0
  3. openscript/accounting/__init__.py +74 -0
  4. openscript/accounting/analysis.py +174 -0
  5. openscript/accounting/charges.py +397 -0
  6. openscript/accounting/equity.py +234 -0
  7. openscript/accounting/report.py +82 -0
  8. openscript/accounting/shapes.py +74 -0
  9. openscript/accounting/statistics.py +300 -0
  10. openscript/accounting/trades.py +294 -0
  11. openscript/adapter/__init__.py +32 -0
  12. openscript/adapter/answers.py +215 -0
  13. openscript/adapter/channels.py +137 -0
  14. openscript/adapter/expectations.py +67 -0
  15. openscript/adapter/facts.py +127 -0
  16. openscript/adapter/matching.py +257 -0
  17. openscript/adapter/ordering.py +187 -0
  18. openscript/adapter/page.py +130 -0
  19. openscript/adapter/reading.py +357 -0
  20. openscript/adapter/reporting.py +244 -0
  21. openscript/adapter/running.py +449 -0
  22. openscript/adapter/serving.py +229 -0
  23. openscript/adapter/sessions.py +168 -0
  24. openscript/adapter/spellings.py +184 -0
  25. openscript/bars.py +157 -0
  26. openscript/budget.py +342 -0
  27. openscript/canonical.py +192 -0
  28. openscript/civil.py +196 -0
  29. openscript/contracts.py +165 -0
  30. openscript/dates.py +302 -0
  31. openscript/diagnostics.py +104 -0
  32. openscript/hours.py +165 -0
  33. openscript/inputs.py +239 -0
  34. openscript/intervals.py +60 -0
  35. openscript/library/__init__.py +76 -0
  36. openscript/library/arithmetic.py +128 -0
  37. openscript/library/averages.py +133 -0
  38. openscript/library/bars.py +60 -0
  39. openscript/library/bookkeeping.py +166 -0
  40. openscript/library/code_points.py +85 -0
  41. openscript/library/colour.py +202 -0
  42. openscript/library/composites.py +208 -0
  43. openscript/library/counting.py +218 -0
  44. openscript/library/deviation.py +155 -0
  45. openscript/library/elementary.py +206 -0
  46. openscript/library/extremes.py +122 -0
  47. openscript/library/flows.py +220 -0
  48. openscript/library/momentum.py +203 -0
  49. openscript/library/number_text.py +223 -0
  50. openscript/library/prices.py +36 -0
  51. openscript/library/ranges.py +105 -0
  52. openscript/library/rounding.py +123 -0
  53. openscript/library/series.py +213 -0
  54. openscript/library/stateful.py +442 -0
  55. openscript/library/stateless.py +261 -0
  56. openscript/library/strength.py +180 -0
  57. openscript/library/strings.py +228 -0
  58. openscript/library/trend.py +260 -0
  59. openscript/library/values.py +91 -0
  60. openscript/logbook.py +119 -0
  61. openscript/machine.py +499 -0
  62. openscript/memory.py +204 -0
  63. openscript/opcodes.py +166 -0
  64. openscript/program.py +146 -0
  65. openscript/run.py +368 -0
  66. openscript/strategy/__init__.py +78 -0
  67. openscript/strategy/calls.py +201 -0
  68. openscript/strategy/closable.py +182 -0
  69. openscript/strategy/fills.py +131 -0
  70. openscript/strategy/holdings.py +277 -0
  71. openscript/strategy/intents.py +162 -0
  72. openscript/strategy/ledger.py +270 -0
  73. openscript/strategy/placing.py +206 -0
  74. openscript/strategy/positions.py +124 -0
  75. openscript/strategy/refusals.py +293 -0
  76. openscript/strategy/rows.py +219 -0
  77. openscript/strategy/sizing.py +229 -0
  78. openscript/strategy/statuses.py +65 -0
  79. openscript/surface/__init__.py +115 -0
  80. openscript/surface/bands.py +103 -0
  81. openscript/surface/levels.py +44 -0
  82. openscript/surface/marks.py +52 -0
  83. openscript/surface/paints.py +58 -0
  84. openscript/surface/plots.py +44 -0
  85. openscript/surface/published.py +119 -0
  86. openscript/values.py +210 -0
  87. openscript/verify.py +301 -0
  88. openscript/verify_code.py +290 -0
  89. openscript/verify_requests.py +271 -0
  90. openscript/verify_shape.py +162 -0
  91. openscript/verify_tables.py +256 -0
  92. openscript/version.py +39 -0
  93. openscript/zones.py +118 -0
  94. openscript-0.4.0.dist-info/METADATA +82 -0
  95. openscript-0.4.0.dist-info/RECORD +97 -0
  96. openscript-0.4.0.dist-info/WHEEL +5 -0
  97. openscript-0.4.0.dist-info/top_level.txt +1 -0
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
@@ -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}