@pineforge/codegen-pyodide 0.10.3 → 1.0.0-rc.1
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.
- package/README.md +16 -16
- package/glue.py +24 -16
- package/package.json +1 -1
- package/pineforge_codegen/__init__.py +125 -34
- package/pineforge_codegen/analyzer/__init__.py +2 -0
- package/pineforge_codegen/analyzer/base.py +767 -77
- package/pineforge_codegen/analyzer/call_handlers.py +268 -42
- package/pineforge_codegen/analyzer/contracts.py +37 -0
- package/pineforge_codegen/analyzer/diagnostics.py +30 -4
- package/pineforge_codegen/analyzer/tables.py +49 -8
- package/pineforge_codegen/analyzer/types.py +33 -1
- package/pineforge_codegen/ast_nodes.py +32 -1
- package/pineforge_codegen/block_locals.py +185 -0
- package/pineforge_codegen/builtin_keywords.py +42 -0
- package/pineforge_codegen/codegen/base.py +907 -159
- package/pineforge_codegen/codegen/constant_fold.py +131 -0
- package/pineforge_codegen/codegen/drawing.py +221 -79
- package/pineforge_codegen/codegen/emit_top.py +961 -213
- package/pineforge_codegen/codegen/helpers.py +435 -14
- package/pineforge_codegen/codegen/host_members.py +162 -0
- package/pineforge_codegen/codegen/input.py +252 -85
- package/pineforge_codegen/codegen/security.py +4372 -377
- package/pineforge_codegen/codegen/session_market.py +71 -0
- package/pineforge_codegen/codegen/ta.py +1198 -101
- package/pineforge_codegen/codegen/tables.py +205 -71
- package/pineforge_codegen/codegen/tv_number_format.py +270 -0
- package/pineforge_codegen/codegen/types.py +2055 -62
- package/pineforge_codegen/codegen/visit_call.py +929 -131
- package/pineforge_codegen/codegen/visit_expr.py +765 -63
- package/pineforge_codegen/codegen/visit_stmt.py +610 -52
- package/pineforge_codegen/external_requests.py +877 -0
- package/pineforge_codegen/lexer.py +104 -22
- package/pineforge_codegen/library_inline.py +1304 -0
- package/pineforge_codegen/library_modules.py +126 -0
- package/pineforge_codegen/library_v5.py +683 -0
- package/pineforge_codegen/limits.py +138 -0
- package/pineforge_codegen/method_binding.py +33 -0
- package/pineforge_codegen/parser.py +384 -68
- package/pineforge_codegen/pine_libraries.py +266 -0
- package/pineforge_codegen/pine_spelling.py +216 -0
- package/pineforge_codegen/pragmas.py +64 -10
- package/pineforge_codegen/security_contexts.py +1585 -0
- package/pineforge_codegen/session_reads.py +84 -0
- package/pineforge_codegen/signatures.py +48 -23
- package/pineforge_codegen/support_checker.py +1106 -85
- package/pineforge_codegen/symbols.py +4 -2
- package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
- package/release.json +2 -2
- package/tables.json +23 -21
- package/transpile.worker.mjs +24 -16
- package/pineforge_codegen-0.10.3.tar.gz +0 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
"""Requests PineForge has no data for, and the reads of requests lowered
|
|
2
|
+
onto data a run is given.
|
|
3
|
+
|
|
4
|
+
``request.security`` on another symbol and ``request.financial`` /
|
|
5
|
+
``earnings`` / ``dividends`` / ``splits`` / ``footprint`` read data the
|
|
6
|
+
engine does not load itself: a probe's requests manifest pins it
|
|
7
|
+
(``PINEFORGE_REQUESTS_ROOT``). A request of another symbol the support checker
|
|
8
|
+
lowers onto that symbol's pinned feed (``FEED_LOWERING``), and a fundamentals
|
|
9
|
+
request lowered onto the series recorded under its key (``RECORDED_LOWERING``,
|
|
10
|
+
``recorded_key``), stay; their reads are marked like a deferred refusal's
|
|
11
|
+
below, and stop the run only when the run began with no data for them.
|
|
12
|
+
``TradeSlice`` follows such a request's value forward
|
|
13
|
+
through the script: when it reaches display and alert sinks only -- the
|
|
14
|
+
backward slice of every trade sink holds no part of it -- the request is
|
|
15
|
+
lowered to ``na`` with a warning (``lower_no_data_requests``). Every other one
|
|
16
|
+
is a deferred refusal: it lowers to a value whose first READ stops the run
|
|
17
|
+
with an error naming the request, since no data is pinned for it. Binding it
|
|
18
|
+
is no read: ``e = request.earnings(...)`` stops the run only where ``e`` is
|
|
19
|
+
read, so a script reading it only in a branch it never takes runs.
|
|
20
|
+
|
|
21
|
+
The slice is conservative. Trade sinks are the order calls (``strategy.*``
|
|
22
|
+
that places, closes or cancels, ``strategy.risk.*``) and ``runtime.error``. A
|
|
23
|
+
value the request taints, and every statement under a condition it taints,
|
|
24
|
+
may reach operators, built-ins that return ``na`` for an ``na`` argument
|
|
25
|
+
(``math.*``, ``color.*``, ``ta.*``, ``nz``, ``str.tostring``, ...), user
|
|
26
|
+
functions (followed through their parameters and results), bindings (a
|
|
27
|
+
``var`` too: bindings are flow-insensitive, so a value that feeds a later
|
|
28
|
+
bar's state is followed), and the display and alert sinks (``alert``,
|
|
29
|
+
``plot*``, ``table.*``, ``log.*``). Anything else -- an order, a collection
|
|
30
|
+
or drawing, a method, ``break``, a field write, a built-in this module does
|
|
31
|
+
not list -- keeps the refusal: the lowered ``na`` could change it where
|
|
32
|
+
TradingView's value would not.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from .ast_nodes import (
|
|
38
|
+
ASTNode, Assignment, BinOp, BoolLiteral, BreakStmt, ContinueStmt,
|
|
39
|
+
ExprStmt, ForInStmt, ForStmt, FuncCall, FuncDef, Identifier, IfStmt,
|
|
40
|
+
MemberAccess, MethodDef, NaLiteral, NumberLiteral, Program, StringLiteral, Subscript,
|
|
41
|
+
SwitchStmt, Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl, WhileStmt,
|
|
42
|
+
)
|
|
43
|
+
from .errors import SourceLocation
|
|
44
|
+
from .security_contexts import ScriptIndex, replace_nodes
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
LOWERING_ANNOTATION = "pf_request_lowering"
|
|
48
|
+
# The lowering of a request of another symbol that reads the feed a requests
|
|
49
|
+
# manifest pins for it (``request.security``; the engine's instrument feeds).
|
|
50
|
+
FEED_LOWERING = "feed"
|
|
51
|
+
# The lowering of ``request.earnings`` / ``dividends`` / ``splits`` /
|
|
52
|
+
# ``financial``: the series TradingView returned per chart bar, recorded under
|
|
53
|
+
# its key (``recorded_key``) by the requests manifest.
|
|
54
|
+
RECORDED_LOWERING = "recorded"
|
|
55
|
+
# Lowerings whose data is looked up at run time: the request stays, and its
|
|
56
|
+
# reads stop the run only where the data is missing.
|
|
57
|
+
DATA_LOWERINGS = frozenset({FEED_LOWERING, RECORDED_LOWERING})
|
|
58
|
+
# The lowering of a ``request.security_lower_tf`` of another symbol whose
|
|
59
|
+
# value can reach a trade: the engine reads no other symbol's intrabars, and
|
|
60
|
+
# the request read the chart's. It stays (its array type flows on), and
|
|
61
|
+
# evaluating it stops the run with the request named.
|
|
62
|
+
ABSENT_LOWERING = "absent"
|
|
63
|
+
# On a recorded request once lowered: its key's parts but the symbol, which
|
|
64
|
+
# stays the call's only argument (``lower_no_data_requests``).
|
|
65
|
+
RECORDED_KEY_ANNOTATION = "pf_recorded_key"
|
|
66
|
+
# Pine's field constants per recorded function, and each default; the
|
|
67
|
+
# financial periods (the requests manifest's key grammar, workflow
|
|
68
|
+
# campaign/src/probe-requests.mjs formatRecordedKey).
|
|
69
|
+
RECORDED_FIELDS = {
|
|
70
|
+
"earnings": (("actual", "estimate", "standardized"), "actual"),
|
|
71
|
+
"dividends": (("gross", "net"), "gross"),
|
|
72
|
+
"splits": (("denominator", "numerator"), "denominator"),
|
|
73
|
+
}
|
|
74
|
+
FINANCIAL_PERIODS = ("FQ", "FY", "FH", "TTM")
|
|
75
|
+
# Each recorded function's parameters, in order.
|
|
76
|
+
_RECORDED_PARAMS = {
|
|
77
|
+
"earnings": ("ticker", "field", "gaps", "lookahead", "ignore_invalid_symbol", "currency"),
|
|
78
|
+
"dividends": ("ticker", "field", "gaps", "lookahead", "ignore_invalid_symbol", "currency"),
|
|
79
|
+
"splits": ("ticker", "field", "gaps", "lookahead", "ignore_invalid_symbol", "currency"),
|
|
80
|
+
"financial": ("symbol", "financial_id", "period", "gaps", "ignore_invalid_symbol",
|
|
81
|
+
"currency"),
|
|
82
|
+
}
|
|
83
|
+
# On a node whose evaluation stops the run: the message it stops with, or,
|
|
84
|
+
# for a request lowered onto pinned data, ``{"message": ..., "ref":
|
|
85
|
+
# RequestRef}``: the run stops there only when no data is installed for the
|
|
86
|
+
# request that carries the same ref (``REQUEST_REF_ANNOTATION``).
|
|
87
|
+
UNPINNED_ANNOTATION = "pf_request_unpinned"
|
|
88
|
+
REQUEST_REF_ANNOTATION = "pf_request_ref"
|
|
89
|
+
# On a request whose symbol can select the chart's or another symbol's: it
|
|
90
|
+
# read the chart before it read a feed, and keeps that lowering where no feed
|
|
91
|
+
# can be keyed (``unpin_requests``).
|
|
92
|
+
CHART_FALLBACK_ANNOTATION = "pf_request_chart_fallback"
|
|
93
|
+
# On a request of another symbol whose symbol or timeframe reaches it through
|
|
94
|
+
# a helper's parameters: the support checker's warning, which
|
|
95
|
+
# ``security_contexts`` reports once every call path keys a feed (else it
|
|
96
|
+
# reports why the request keeps its earlier lowering).
|
|
97
|
+
FEED_WARNING_ANNOTATION = "pf_request_feed_warning"
|
|
98
|
+
# On a ``request.footprint`` that is the whole expression of a request of
|
|
99
|
+
# another symbol reading its feed: the feed column its delta is read from.
|
|
100
|
+
FOOTPRINT_COLUMN_ANNOTATION = "pf_footprint_column"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RequestRef:
|
|
104
|
+
"""Names a request lowered onto pinned data from the reads marked for
|
|
105
|
+
it. A plain object, not a node, so no AST walk follows it; a helper copy
|
|
106
|
+
(``security_contexts``) copies it once for the request and its reads."""
|
|
107
|
+
# request.* calls whose data PineForge never has.
|
|
108
|
+
NO_DATA_REQUEST_FUNCS = frozenset({"financial", "earnings", "dividends", "splits", "footprint"})
|
|
109
|
+
|
|
110
|
+
_ORDER_CALLS = frozenset({
|
|
111
|
+
"entry", "order", "exit", "close", "close_all", "cancel", "cancel_all",
|
|
112
|
+
})
|
|
113
|
+
_DISPLAY_CALLS = frozenset({
|
|
114
|
+
"alert", "alertcondition", "plot", "plotshape", "plotchar", "plotarrow",
|
|
115
|
+
"plotcandle", "plotbar", "bgcolor", "barcolor", "fill", "hline",
|
|
116
|
+
})
|
|
117
|
+
_DISPLAY_NAMESPACES = frozenset({"table", "log"})
|
|
118
|
+
# Built-ins that return na (or a value) for an na argument, never stop the
|
|
119
|
+
# script, and change nothing but their own result.
|
|
120
|
+
_NA_SAFE_CALLS = frozenset({"na", "nz", "fixnan", "int", "float", "bool", "string"})
|
|
121
|
+
_NA_SAFE_NAMESPACES = frozenset({"math", "color", "ta"})
|
|
122
|
+
_NA_SAFE_STR = frozenset({
|
|
123
|
+
"tostring", "format", "length", "upper", "lower", "contains", "startswith",
|
|
124
|
+
"endswith", "trim", "replace", "replace_all",
|
|
125
|
+
})
|
|
126
|
+
_BOOL_TA = frozenset({"cross", "crossover", "crossunder", "rising", "falling"})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _call_name(node: FuncCall) -> tuple[str | None, str | None]:
|
|
130
|
+
callee = node.callee
|
|
131
|
+
if isinstance(callee, Identifier):
|
|
132
|
+
return None, callee.name
|
|
133
|
+
if isinstance(callee, MemberAccess):
|
|
134
|
+
parts = []
|
|
135
|
+
obj = callee.object
|
|
136
|
+
while isinstance(obj, MemberAccess):
|
|
137
|
+
parts.append(obj.member)
|
|
138
|
+
obj = obj.object
|
|
139
|
+
if isinstance(obj, Identifier):
|
|
140
|
+
parts.append(obj.name)
|
|
141
|
+
return ".".join(reversed(parts)), callee.member
|
|
142
|
+
return None, None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def recorded_key(request: FuncCall) -> tuple[dict | None, str | None]:
|
|
146
|
+
"""``(parts, None)`` of a recorded request's key but its symbol --
|
|
147
|
+
``{"fn", "symbol", "field", "period", "gaps", "lookahead"}``, the symbol
|
|
148
|
+
the argument node -- or ``(None, why)`` for a spelling no key names: a
|
|
149
|
+
field constant of another namespace or none, a financial id or period
|
|
150
|
+
that is not a literal of the grammar, a ``gaps``/``lookahead`` that is
|
|
151
|
+
not a ``barmerge`` constant, a ``currency`` (the tape records no
|
|
152
|
+
conversion)."""
|
|
153
|
+
ns, fn = _call_name(request)
|
|
154
|
+
if ns != "request" or fn not in _RECORDED_PARAMS:
|
|
155
|
+
return None, "it is no recorded request"
|
|
156
|
+
params = _RECORDED_PARAMS[fn]
|
|
157
|
+
if len(request.args) > len(params):
|
|
158
|
+
return None, "it has more arguments than the request takes"
|
|
159
|
+
args = dict(zip(params, request.args))
|
|
160
|
+
for name, value in request.kwargs.items():
|
|
161
|
+
if name not in params or name in args:
|
|
162
|
+
return None, f"its argument {name} is not one the key names"
|
|
163
|
+
args[name] = value
|
|
164
|
+
if "currency" in args:
|
|
165
|
+
return None, "it converts to a currency, which no recorded key names"
|
|
166
|
+
symbol = args.get(params[0])
|
|
167
|
+
if symbol is None:
|
|
168
|
+
return None, "it names no symbol"
|
|
169
|
+
|
|
170
|
+
def flag(name: str, kind: str) -> str | None:
|
|
171
|
+
value = args.get(name)
|
|
172
|
+
if value is None:
|
|
173
|
+
return "off"
|
|
174
|
+
if (isinstance(value, MemberAccess) and isinstance(value.object, Identifier)
|
|
175
|
+
and value.object.name == "barmerge"
|
|
176
|
+
and value.member in (f"{kind}_on", f"{kind}_off")):
|
|
177
|
+
return value.member.rsplit("_", 1)[1]
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
gaps = flag("gaps", "gaps")
|
|
181
|
+
lookahead = flag("lookahead", "lookahead") if fn != "financial" else "off"
|
|
182
|
+
if gaps is None or lookahead is None:
|
|
183
|
+
return None, "its gaps and lookahead are barmerge constants"
|
|
184
|
+
if fn == "financial":
|
|
185
|
+
fid, period = args.get("financial_id"), args.get("period")
|
|
186
|
+
if not (isinstance(fid, StringLiteral) and fid.value[:1].isalpha()
|
|
187
|
+
and fid.value.replace("_", "").isalnum() and fid.value == fid.value.upper()):
|
|
188
|
+
return None, "its financial_id is a literal TradingView financial id"
|
|
189
|
+
if not (isinstance(period, StringLiteral) and period.value in FINANCIAL_PERIODS):
|
|
190
|
+
return None, f"its period is one of the literals {', '.join(FINANCIAL_PERIODS)}"
|
|
191
|
+
field_text, period_text = fid.value, period.value
|
|
192
|
+
else:
|
|
193
|
+
fields, default = RECORDED_FIELDS[fn]
|
|
194
|
+
value = args.get("field")
|
|
195
|
+
if value is None:
|
|
196
|
+
field_text = default
|
|
197
|
+
elif (isinstance(value, MemberAccess) and isinstance(value.object, Identifier)
|
|
198
|
+
and value.object.name == fn and value.member in fields):
|
|
199
|
+
field_text = value.member
|
|
200
|
+
else:
|
|
201
|
+
return None, f"its field is one of the {fn}.* constants"
|
|
202
|
+
period_text = "-"
|
|
203
|
+
return {"fn": fn, "symbol": symbol, "field": field_text, "period": period_text,
|
|
204
|
+
"gaps": gaps, "lookahead": lookahead}, None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def footprint_column(request) -> str | None:
|
|
208
|
+
"""``fp_delta_<ticks>_<va>``: the feed column a ``request.footprint``
|
|
209
|
+
call with literal ticks per row and value-area percent reads its delta
|
|
210
|
+
from (the requests manifest's column names), else None."""
|
|
211
|
+
if not (isinstance(request, FuncCall) and _call_name(request) == ("request", "footprint")):
|
|
212
|
+
return None
|
|
213
|
+
ticks = request.args[0] if request.args else request.kwargs.get("ticks_per_row")
|
|
214
|
+
va = (request.args[1] if len(request.args) > 1 else request.kwargs.get("va_percent",
|
|
215
|
+
NumberLiteral(value=70)))
|
|
216
|
+
values = []
|
|
217
|
+
for arg in (ticks, va):
|
|
218
|
+
if not (isinstance(arg, NumberLiteral) and float(arg.value).is_integer()
|
|
219
|
+
and int(arg.value) > 0):
|
|
220
|
+
return None
|
|
221
|
+
values.append(int(arg.value))
|
|
222
|
+
return f"fp_delta_{values[0]}_{values[1]}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _footprint_payload(request) -> FuncCall | None:
|
|
226
|
+
"""The ``request.footprint`` call that is ``request``'s whole
|
|
227
|
+
expression (``request.security(sym, tf, request.footprint(100, 70))``)."""
|
|
228
|
+
if isinstance(request, FuncCall) and _call_name(request) == ("request", "security"):
|
|
229
|
+
payload = request.args[2] if len(request.args) > 2 else request.kwargs.get("expression")
|
|
230
|
+
if isinstance(payload, FuncCall) and _call_name(payload) == ("request", "footprint"):
|
|
231
|
+
return payload
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class FootprintValues:
|
|
236
|
+
"""The script's footprint values, as ``ScriptIndex`` bindings:
|
|
237
|
+
declarations and helper parameters typed ``footprint``, declarations
|
|
238
|
+
holding a ``request.footprint`` (inside a ``request.security`` or not) or
|
|
239
|
+
a helper's call returning one, and the parameters a helper's calls pass
|
|
240
|
+
one to. PineForge reads a footprint's ``delta()`` only, so a footprint
|
|
241
|
+
value is its delta."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, index: ScriptIndex) -> None:
|
|
244
|
+
self.index = index
|
|
245
|
+
self.bindings: set[tuple] = {
|
|
246
|
+
("param", name, param) for name, fdef in index.funcs.items()
|
|
247
|
+
for param, hint in zip(fdef.params, _param_type_hints(fdef))
|
|
248
|
+
if str(hint or "").strip() == "footprint"}
|
|
249
|
+
self.returns: set[str] = set() # helpers returning a footprint
|
|
250
|
+
decls = [(binding, decl) for binding, decl in index.decls.items()
|
|
251
|
+
if isinstance(decl, VarDecl)]
|
|
252
|
+
changed = True
|
|
253
|
+
while changed:
|
|
254
|
+
changed = False
|
|
255
|
+
for binding, decl in decls:
|
|
256
|
+
if binding not in self.bindings and (
|
|
257
|
+
str(decl.type_hint or "").strip() == "footprint"
|
|
258
|
+
or self.holds(decl.value)):
|
|
259
|
+
self.bindings.add(binding)
|
|
260
|
+
changed = True
|
|
261
|
+
for name, fdef in index.funcs.items():
|
|
262
|
+
last = fdef.body[-1] if fdef.body else None
|
|
263
|
+
if (name not in self.returns and isinstance(last, ExprStmt)
|
|
264
|
+
and self.holds(last.expr)):
|
|
265
|
+
self.returns.add(name)
|
|
266
|
+
changed = True
|
|
267
|
+
for param in fdef.params:
|
|
268
|
+
if ("param", name, param) not in self.bindings and any(
|
|
269
|
+
self.holds(index.param_arg(name, call, param))
|
|
270
|
+
for call in index.calls.get(name, ())):
|
|
271
|
+
self.bindings.add(("param", name, param))
|
|
272
|
+
changed = True
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def is_request(expr) -> bool:
|
|
276
|
+
return (isinstance(expr, FuncCall) and _call_name(expr) == ("request", "footprint")
|
|
277
|
+
or _footprint_payload(expr) is not None)
|
|
278
|
+
|
|
279
|
+
def holds(self, expr) -> bool:
|
|
280
|
+
"""``expr`` is a footprint value."""
|
|
281
|
+
if isinstance(expr, Identifier):
|
|
282
|
+
return self.index.refs.get(id(expr)) in self.bindings
|
|
283
|
+
if (isinstance(expr, FuncCall) and id(expr) in self.index.call_ids
|
|
284
|
+
and expr.callee.name in self.returns):
|
|
285
|
+
return True
|
|
286
|
+
return self.is_request(expr)
|
|
287
|
+
|
|
288
|
+
def member(self, call: FuncCall) -> tuple[str, ASTNode | None, bool] | None:
|
|
289
|
+
"""``(member, footprint, only argument)`` of ``fp.member(...)`` or
|
|
290
|
+
``footprint.member(fp, ...)``, else None; the flag says the footprint
|
|
291
|
+
is the call's only argument."""
|
|
292
|
+
callee = call.callee
|
|
293
|
+
if not isinstance(callee, MemberAccess):
|
|
294
|
+
return None
|
|
295
|
+
arity = len(call.args) + len(call.kwargs)
|
|
296
|
+
if (isinstance(callee.object, Identifier) and callee.object.name == "footprint"
|
|
297
|
+
and id(callee.object) not in self.index.refs):
|
|
298
|
+
first = call.args[0] if call.args else call.kwargs.get("id")
|
|
299
|
+
if first is None or not self.holds(first):
|
|
300
|
+
return None
|
|
301
|
+
return callee.member, first, arity == 1
|
|
302
|
+
if self.holds(callee.object):
|
|
303
|
+
return callee.member, callee.object, arity == 0
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _param_type_hints(fdef: FuncDef) -> list:
|
|
308
|
+
return list((fdef.annotations or {}).get("param_type_hints") or ())
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def read_footprint_deltas(program: Program) -> None:
|
|
312
|
+
"""``fp.delta()`` and ``footprint.delta(fp)`` read the footprint's
|
|
313
|
+
delta, which is the value a footprint lowers to: each becomes ``fp``, and
|
|
314
|
+
a declaration typed ``footprint`` a float. The support checker refuses
|
|
315
|
+
every other footprint member."""
|
|
316
|
+
if not any((isinstance(n, Identifier) and n.name == "footprint")
|
|
317
|
+
or (isinstance(n, MemberAccess) and n.member == "footprint")
|
|
318
|
+
or (isinstance(n, VarDecl) and str(n.type_hint or "").strip() == "footprint")
|
|
319
|
+
for n in _nodes(program)):
|
|
320
|
+
return # most scripts name no footprint: index none for them
|
|
321
|
+
values = FootprintValues(ScriptIndex(program))
|
|
322
|
+
swaps: dict[int, ASTNode] = {}
|
|
323
|
+
for node in _nodes(program):
|
|
324
|
+
if isinstance(node, FuncCall):
|
|
325
|
+
read = values.member(node)
|
|
326
|
+
if read is not None and read[0] == "delta" and read[2]:
|
|
327
|
+
swaps[id(node)] = read[1]
|
|
328
|
+
elif isinstance(node, VarDecl) and str(node.type_hint or "").strip() == "footprint":
|
|
329
|
+
node.type_hint = "float"
|
|
330
|
+
elif isinstance(node, FuncDef) and any(
|
|
331
|
+
str(hint or "").strip() == "footprint" for hint in _param_type_hints(node)):
|
|
332
|
+
node.annotations = {**node.annotations, "param_type_hints": [
|
|
333
|
+
"float" if str(hint or "").strip() == "footprint" else hint
|
|
334
|
+
for hint in _param_type_hints(node)]}
|
|
335
|
+
if swaps:
|
|
336
|
+
replace_nodes(program, swaps)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def no_data_request(node) -> str | None:
|
|
340
|
+
"""``financial`` ... ``footprint`` for a request PineForge has no data
|
|
341
|
+
for by its function alone, else None."""
|
|
342
|
+
if isinstance(node, FuncCall):
|
|
343
|
+
ns, name = _call_name(node)
|
|
344
|
+
if ns == "request" and name in NO_DATA_REQUEST_FUNCS:
|
|
345
|
+
return name
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def spell_call(node: FuncCall) -> str:
|
|
350
|
+
"""The call as a message names it: callee and its first arguments."""
|
|
351
|
+
ns, name = _call_name(node)
|
|
352
|
+
head = f"{ns}.{name}" if ns else str(name)
|
|
353
|
+
|
|
354
|
+
def arg(value) -> str:
|
|
355
|
+
if isinstance(value, StringLiteral):
|
|
356
|
+
return f'"{value.value}"'
|
|
357
|
+
if isinstance(value, Identifier):
|
|
358
|
+
return value.name
|
|
359
|
+
if isinstance(value, MemberAccess):
|
|
360
|
+
inner = arg(value.object)
|
|
361
|
+
return f"{inner}.{value.member}" if inner != "..." else "..."
|
|
362
|
+
if isinstance(value, FuncCall):
|
|
363
|
+
return spell_call(value)
|
|
364
|
+
return "..."
|
|
365
|
+
|
|
366
|
+
shown = [arg(a) for a in node.args[:2]]
|
|
367
|
+
if len(node.args) > 2 or node.kwargs:
|
|
368
|
+
shown.append("...")
|
|
369
|
+
line = f" at line {node.loc.line}" if node.loc is not None else ""
|
|
370
|
+
return f"{head}({', '.join(shown)}){line}"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class TradeSlice:
|
|
374
|
+
"""Whether a request's value can reach a trade (module docstring)."""
|
|
375
|
+
|
|
376
|
+
def __init__(self, program: Program) -> None:
|
|
377
|
+
self.index = ScriptIndex(program)
|
|
378
|
+
self.program = program
|
|
379
|
+
self.methods = {s.name: s for s in program.body if isinstance(s, MethodDef)}
|
|
380
|
+
|
|
381
|
+
def reason(self, request: FuncCall) -> str | None:
|
|
382
|
+
"""None when ``request``'s value reaches no trade sink, else the use
|
|
383
|
+
that may."""
|
|
384
|
+
return _Taint(self, request).run()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class _Unsafe(Exception):
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class _Taint:
|
|
392
|
+
def __init__(self, slice_: TradeSlice, request: FuncCall) -> None:
|
|
393
|
+
self.s = slice_
|
|
394
|
+
self.idx = slice_.index
|
|
395
|
+
self.request = request
|
|
396
|
+
self.tainted: set[tuple] = set()
|
|
397
|
+
self.returns: set[str] = set() # user functions / methods returning taint
|
|
398
|
+
self.guarded: set[str] = set() # functions called under tainted control
|
|
399
|
+
self.changed = True
|
|
400
|
+
|
|
401
|
+
def run(self) -> str | None:
|
|
402
|
+
try:
|
|
403
|
+
self._payload_is_pure()
|
|
404
|
+
while self.changed:
|
|
405
|
+
self.changed = False
|
|
406
|
+
self._block(self.s.program.body, False)
|
|
407
|
+
for name, fdef in self.idx.funcs.items():
|
|
408
|
+
self._callable(name, fdef, name in self.guarded)
|
|
409
|
+
for name, mdef in self.s.methods.items():
|
|
410
|
+
self._callable(name, mdef, False)
|
|
411
|
+
except _Unsafe as exc:
|
|
412
|
+
return str(exc)
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
# -- the value ------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
def _add(self, binding) -> None:
|
|
418
|
+
if binding is not None and binding not in self.tainted:
|
|
419
|
+
self.tainted.add(binding)
|
|
420
|
+
self.changed = True
|
|
421
|
+
|
|
422
|
+
def _reads_taint(self, expr) -> bool:
|
|
423
|
+
for node in _nodes(expr):
|
|
424
|
+
if node is self.request:
|
|
425
|
+
return True
|
|
426
|
+
if isinstance(node, Identifier) and self.idx.refs.get(id(node)) in self.tainted:
|
|
427
|
+
return True
|
|
428
|
+
if isinstance(node, FuncCall):
|
|
429
|
+
ns, name = _call_name(node)
|
|
430
|
+
if ns is None and name in self.returns and name in self.idx.funcs:
|
|
431
|
+
return True
|
|
432
|
+
if (isinstance(node.callee, MemberAccess) and name in self.returns
|
|
433
|
+
and name in self.s.methods):
|
|
434
|
+
return True
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
def _payload_is_pure(self) -> None:
|
|
438
|
+
"""A dropped request runs nothing: its arguments, the payload's too,
|
|
439
|
+
may only compute."""
|
|
440
|
+
seen: set[str] = set()
|
|
441
|
+
stack = [list(self.request.args) + list(self.request.kwargs.values())]
|
|
442
|
+
while stack:
|
|
443
|
+
for node in _nodes(stack.pop()):
|
|
444
|
+
if not isinstance(node, FuncCall):
|
|
445
|
+
continue
|
|
446
|
+
ns, name = _call_name(node)
|
|
447
|
+
if ns is None and name in self.idx.funcs:
|
|
448
|
+
if name not in seen:
|
|
449
|
+
seen.add(name)
|
|
450
|
+
stack.append(self.idx.funcs[name].body)
|
|
451
|
+
continue
|
|
452
|
+
if self._is_order(ns, name):
|
|
453
|
+
raise _Unsafe(f"its expression calls {ns}.{name}")
|
|
454
|
+
if not (self._is_display(node, ns, name) or self._is_pure(ns, name)):
|
|
455
|
+
raise _Unsafe(f"its expression calls {_label(ns, name)}")
|
|
456
|
+
|
|
457
|
+
# -- statements -------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def _callable(self, name: str, fdef, guarded: bool) -> None:
|
|
460
|
+
self._block(fdef.body, guarded)
|
|
461
|
+
if fdef.body and name not in self.returns:
|
|
462
|
+
last = fdef.body[-1]
|
|
463
|
+
value = last.expr if isinstance(last, ExprStmt) else last
|
|
464
|
+
if self._reads_taint(value):
|
|
465
|
+
self.returns.add(name)
|
|
466
|
+
self.changed = True
|
|
467
|
+
|
|
468
|
+
def _block(self, stmts, guarded: bool) -> None:
|
|
469
|
+
for stmt in stmts:
|
|
470
|
+
self._stmt(stmt, guarded)
|
|
471
|
+
|
|
472
|
+
def _stmt(self, stmt, guarded: bool) -> None:
|
|
473
|
+
if isinstance(stmt, (FuncDef, MethodDef)):
|
|
474
|
+
return
|
|
475
|
+
if isinstance(stmt, VarDecl):
|
|
476
|
+
self._expr(stmt.value, guarded)
|
|
477
|
+
if guarded or self._reads_taint(stmt.value):
|
|
478
|
+
self._add(self.idx.decl_binding.get((id(stmt), stmt.name)))
|
|
479
|
+
return
|
|
480
|
+
if isinstance(stmt, TupleAssign):
|
|
481
|
+
self._expr(stmt.value, guarded)
|
|
482
|
+
if guarded or self._reads_taint(stmt.value):
|
|
483
|
+
for name in stmt.names:
|
|
484
|
+
self._add(self.idx.decl_binding.get((id(stmt), name)))
|
|
485
|
+
return
|
|
486
|
+
if isinstance(stmt, Assignment):
|
|
487
|
+
self._expr(stmt.value, guarded)
|
|
488
|
+
tainted = guarded or self._reads_taint(stmt.value)
|
|
489
|
+
if isinstance(stmt.target, Identifier):
|
|
490
|
+
if tainted:
|
|
491
|
+
self._add(self.idx.refs.get(id(stmt.target)))
|
|
492
|
+
elif tainted or self._reads_taint(stmt.target):
|
|
493
|
+
raise _Unsafe("it is written to a field or element")
|
|
494
|
+
else:
|
|
495
|
+
self._expr(stmt.target, guarded)
|
|
496
|
+
return
|
|
497
|
+
if isinstance(stmt, IfStmt):
|
|
498
|
+
self._expr(stmt.condition, guarded)
|
|
499
|
+
inner = guarded or self._reads_taint(stmt.condition)
|
|
500
|
+
self._block(stmt.body, inner)
|
|
501
|
+
self._block(stmt.else_body, inner)
|
|
502
|
+
return
|
|
503
|
+
if isinstance(stmt, SwitchStmt):
|
|
504
|
+
parts = [stmt.expr] + [case for case, _ in stmt.cases]
|
|
505
|
+
for part in parts:
|
|
506
|
+
self._expr(part, guarded)
|
|
507
|
+
inner = guarded or any(self._reads_taint(p) for p in parts)
|
|
508
|
+
for _, body in stmt.cases:
|
|
509
|
+
self._block(body, inner)
|
|
510
|
+
self._block(stmt.default_body, inner)
|
|
511
|
+
return
|
|
512
|
+
if isinstance(stmt, (ForStmt, ForInStmt, WhileStmt)):
|
|
513
|
+
heads = ([stmt.start, stmt.end, stmt.step] if isinstance(stmt, ForStmt)
|
|
514
|
+
else [stmt.iterable] if isinstance(stmt, ForInStmt) else [stmt.condition])
|
|
515
|
+
for head in heads:
|
|
516
|
+
self._expr(head, guarded)
|
|
517
|
+
inner = guarded or any(self._reads_taint(h) for h in heads)
|
|
518
|
+
if inner:
|
|
519
|
+
names = ([stmt.var] if isinstance(stmt, ForStmt)
|
|
520
|
+
else [stmt.var] if isinstance(stmt, ForInStmt) and stmt.var
|
|
521
|
+
else list(getattr(stmt, "vars", None) or ()))
|
|
522
|
+
for node in _nodes(stmt.body):
|
|
523
|
+
if isinstance(node, Identifier) and node.name in names:
|
|
524
|
+
self._add(self.idx.refs.get(id(node)))
|
|
525
|
+
self._block(stmt.body, inner)
|
|
526
|
+
return
|
|
527
|
+
if isinstance(stmt, (BreakStmt, ContinueStmt)):
|
|
528
|
+
if guarded:
|
|
529
|
+
raise _Unsafe("it decides a loop's break or continue")
|
|
530
|
+
return
|
|
531
|
+
if isinstance(stmt, ExprStmt):
|
|
532
|
+
self._expr(stmt.expr, guarded)
|
|
533
|
+
return
|
|
534
|
+
self._expr(stmt, guarded)
|
|
535
|
+
|
|
536
|
+
# -- expressions ------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
def _expr(self, expr, guarded: bool) -> None:
|
|
539
|
+
"""Check every call ``expr`` makes, under ``guarded`` control and the
|
|
540
|
+
lazy operands' own conditions."""
|
|
541
|
+
if expr is None or expr is self.request:
|
|
542
|
+
return
|
|
543
|
+
if isinstance(expr, (list, tuple)):
|
|
544
|
+
for item in expr:
|
|
545
|
+
self._expr(item, guarded)
|
|
546
|
+
return
|
|
547
|
+
if isinstance(expr, (IfStmt, SwitchStmt)):
|
|
548
|
+
self._stmt(expr, guarded)
|
|
549
|
+
return
|
|
550
|
+
if not isinstance(expr, ASTNode):
|
|
551
|
+
return
|
|
552
|
+
if isinstance(expr, Ternary):
|
|
553
|
+
self._expr(expr.condition, guarded)
|
|
554
|
+
inner = guarded or self._reads_taint(expr.condition)
|
|
555
|
+
self._expr(expr.true_val, inner)
|
|
556
|
+
self._expr(expr.false_val, inner)
|
|
557
|
+
return
|
|
558
|
+
if isinstance(expr, BinOp) and expr.op in ("and", "or"):
|
|
559
|
+
self._expr(expr.left, guarded)
|
|
560
|
+
self._expr(expr.right, guarded or self._reads_taint(expr.left))
|
|
561
|
+
return
|
|
562
|
+
if isinstance(expr, FuncCall):
|
|
563
|
+
self._call(expr, guarded)
|
|
564
|
+
if isinstance(expr, Subscript) and self._reads_taint(expr.index):
|
|
565
|
+
# A history offset of na can stop the script where TradingView's
|
|
566
|
+
# value would not.
|
|
567
|
+
raise _Unsafe("it is a history offset")
|
|
568
|
+
for key, value in vars(expr).items():
|
|
569
|
+
if key not in ("loc", "annotations", "callee"):
|
|
570
|
+
self._expr(value, guarded)
|
|
571
|
+
|
|
572
|
+
def _call(self, call: FuncCall, guarded: bool) -> None:
|
|
573
|
+
ns, name = _call_name(call)
|
|
574
|
+
args = list(call.args) + list(call.kwargs.values())
|
|
575
|
+
receiver = call.callee.object if isinstance(call.callee, MemberAccess) else None
|
|
576
|
+
if receiver is not None and not (
|
|
577
|
+
isinstance(receiver, Identifier) and id(receiver) not in self.idx.refs):
|
|
578
|
+
# A method call's receiver is an argument (``arr.push(x)``).
|
|
579
|
+
args.append(receiver)
|
|
580
|
+
tainted_args = [a for a in args if self._reads_taint(a)]
|
|
581
|
+
if ns is None and name in self.idx.funcs:
|
|
582
|
+
fdef = self.idx.funcs[name]
|
|
583
|
+
for param in fdef.params:
|
|
584
|
+
arg = self.idx.param_arg(name, call, param)
|
|
585
|
+
if arg is not None and self._reads_taint(arg):
|
|
586
|
+
self._add(("param", name, param))
|
|
587
|
+
if guarded and name not in self.guarded:
|
|
588
|
+
self.guarded.add(name)
|
|
589
|
+
self.changed = True
|
|
590
|
+
return
|
|
591
|
+
if not (guarded or tainted_args):
|
|
592
|
+
return
|
|
593
|
+
if self._is_order(ns, name):
|
|
594
|
+
raise _Unsafe(f"it reaches {_label(ns, name)}")
|
|
595
|
+
if self._is_display(call, ns, name):
|
|
596
|
+
return
|
|
597
|
+
if tainted_args and self._is_na_safe(ns, name):
|
|
598
|
+
return
|
|
599
|
+
if not tainted_args and self._is_pure(ns, name):
|
|
600
|
+
return
|
|
601
|
+
raise _Unsafe(f"it reaches {_label(ns, name)}")
|
|
602
|
+
|
|
603
|
+
# -- call families ------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
@staticmethod
|
|
606
|
+
def _is_order(ns, name) -> bool:
|
|
607
|
+
return ((ns == "strategy" and name in _ORDER_CALLS) or ns == "strategy.risk"
|
|
608
|
+
or (ns, name) == ("runtime", "error"))
|
|
609
|
+
|
|
610
|
+
def _is_display(self, call: FuncCall, ns, name) -> bool:
|
|
611
|
+
if ns is None and name in _DISPLAY_CALLS:
|
|
612
|
+
return True
|
|
613
|
+
if ns in _DISPLAY_NAMESPACES:
|
|
614
|
+
return True
|
|
615
|
+
# A method on a table: ``board.cell(...)``.
|
|
616
|
+
receiver = call.callee.object if isinstance(call.callee, MemberAccess) else None
|
|
617
|
+
if isinstance(receiver, Identifier):
|
|
618
|
+
binding = self.idx.refs.get(id(receiver))
|
|
619
|
+
decl = self.idx.decls.get(binding) if binding is not None else None
|
|
620
|
+
if decl is not None and (str(decl.type_hint or "").strip() == "table" or (
|
|
621
|
+
isinstance(decl.value, FuncCall)
|
|
622
|
+
and _call_name(decl.value) == ("table", "new"))):
|
|
623
|
+
return True
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
@staticmethod
|
|
627
|
+
def _is_na_safe(ns, name) -> bool:
|
|
628
|
+
return ((ns is None and name in _NA_SAFE_CALLS) or ns in _NA_SAFE_NAMESPACES
|
|
629
|
+
or (ns == "str" and name in _NA_SAFE_STR))
|
|
630
|
+
|
|
631
|
+
def _is_pure(self, ns, name) -> bool:
|
|
632
|
+
"""A built-in that changes nothing but its own result."""
|
|
633
|
+
if self._is_na_safe(ns, name):
|
|
634
|
+
return True
|
|
635
|
+
if ns in ("str", "timeframe", "syminfo", "ticker", "input", "chart.point"):
|
|
636
|
+
return True
|
|
637
|
+
if ns == "request" and name in ("security", "security_lower_tf"):
|
|
638
|
+
return True
|
|
639
|
+
return ns is None and name in (
|
|
640
|
+
"time", "time_close", "timestamp", "hour", "minute", "second",
|
|
641
|
+
"dayofmonth", "dayofweek", "month", "year", "weekofyear",
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _label(ns, name) -> str:
|
|
646
|
+
return f"{ns}.{name}(...)" if ns else f"{name}(...)"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _nodes(value):
|
|
650
|
+
"""Every AST node below ``value`` (annotations and locations aside)."""
|
|
651
|
+
stack = [value]
|
|
652
|
+
while stack:
|
|
653
|
+
item = stack.pop()
|
|
654
|
+
if isinstance(item, ASTNode):
|
|
655
|
+
yield item
|
|
656
|
+
stack.extend(v for k, v in vars(item).items() if k not in ("loc", "annotations"))
|
|
657
|
+
elif isinstance(item, (list, tuple)):
|
|
658
|
+
stack.extend(item)
|
|
659
|
+
elif isinstance(item, dict):
|
|
660
|
+
stack.extend(item.values())
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
# Lowering
|
|
665
|
+
# ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
def _na_like(expr, funcs: dict[str, FuncDef], depth: int = 0):
|
|
668
|
+
"""An ``na`` of ``expr``'s shape: a tuple of them for a tuple, ``false``
|
|
669
|
+
for a bool (TradingView reads an na bool as false), an empty string for
|
|
670
|
+
a string, else a float ``na``."""
|
|
671
|
+
if depth > 8:
|
|
672
|
+
return NaLiteral(loc=getattr(expr, "loc", None))
|
|
673
|
+
loc = getattr(expr, "loc", None)
|
|
674
|
+
if isinstance(expr, TupleLiteral):
|
|
675
|
+
return TupleLiteral(elements=[_na_like(e, funcs, depth + 1) for e in expr.elements], loc=loc)
|
|
676
|
+
if isinstance(expr, FuncCall):
|
|
677
|
+
ns, name = _call_name(expr)
|
|
678
|
+
if ns is None and name in funcs and funcs[name].body:
|
|
679
|
+
last = funcs[name].body[-1]
|
|
680
|
+
value = last.expr if isinstance(last, ExprStmt) else None
|
|
681
|
+
if isinstance(value, TupleLiteral):
|
|
682
|
+
local = {s.name: s.value for s in funcs[name].body if isinstance(s, VarDecl)}
|
|
683
|
+
return TupleLiteral(elements=[
|
|
684
|
+
_na_scalar(e, local, loc) for e in value.elements], loc=loc)
|
|
685
|
+
if value is not None:
|
|
686
|
+
return _na_like(value, funcs, depth + 1)
|
|
687
|
+
return _na_scalar(expr, {}, loc)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _na_scalar(expr, local: dict, loc, depth: int = 0):
|
|
691
|
+
kind = _kind(expr, local, depth)
|
|
692
|
+
if kind == "bool":
|
|
693
|
+
return BoolLiteral(value=False, loc=loc)
|
|
694
|
+
if kind == "string":
|
|
695
|
+
return StringLiteral(value="", loc=loc)
|
|
696
|
+
return NaLiteral(loc=loc)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _kind(expr, local: dict, depth: int = 0) -> str:
|
|
700
|
+
if depth > 8:
|
|
701
|
+
return "float"
|
|
702
|
+
if isinstance(expr, BoolLiteral):
|
|
703
|
+
return "bool"
|
|
704
|
+
if isinstance(expr, StringLiteral):
|
|
705
|
+
return "string"
|
|
706
|
+
if isinstance(expr, UnaryOp) and expr.op == "not":
|
|
707
|
+
return "bool"
|
|
708
|
+
if isinstance(expr, BinOp):
|
|
709
|
+
if expr.op in ("==", "!=", "<", "<=", ">", ">=", "and", "or"):
|
|
710
|
+
return "bool"
|
|
711
|
+
if expr.op == "+" and "string" in (_kind(expr.left, local, depth + 1),
|
|
712
|
+
_kind(expr.right, local, depth + 1)):
|
|
713
|
+
return "string"
|
|
714
|
+
return "float"
|
|
715
|
+
if isinstance(expr, Ternary):
|
|
716
|
+
return _kind(expr.true_val, local, depth + 1)
|
|
717
|
+
if isinstance(expr, Identifier) and expr.name in local:
|
|
718
|
+
return _kind(local[expr.name], local, depth + 1)
|
|
719
|
+
if isinstance(expr, Subscript):
|
|
720
|
+
return _kind(expr.object, local, depth + 1)
|
|
721
|
+
if isinstance(expr, FuncCall):
|
|
722
|
+
ns, name = _call_name(expr)
|
|
723
|
+
if ns == "ta" and name in _BOOL_TA or (ns, name) == (None, "na"):
|
|
724
|
+
return "bool"
|
|
725
|
+
if ns == "str" and name not in ("length", "pos", "tonumber"):
|
|
726
|
+
return "string"
|
|
727
|
+
return "float"
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def unpinned_message(node: FuncCall) -> str:
|
|
731
|
+
"""What the run stops with when it reads a request with no data."""
|
|
732
|
+
return f"{spell_call(node)}: no data is pinned for this request, and its value was read"
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _na_of(request: FuncCall, funcs: dict[str, FuncDef]) -> ASTNode:
|
|
736
|
+
"""The ``na`` a request lowers to when no data is read for it."""
|
|
737
|
+
payload = None if no_data_request(request) else (
|
|
738
|
+
request.args[2] if len(request.args) > 2 else request.kwargs.get("expression"))
|
|
739
|
+
return _na_like(payload, funcs) if payload is not None else NaLiteral(loc=request.loc)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _mark_reads(program: Program, index: ScriptIndex, declarations: dict,
|
|
743
|
+
request: FuncCall, evaluated: ASTNode, marker) -> None:
|
|
744
|
+
"""Mark where ``request``'s value is read with ``marker``: the reads of
|
|
745
|
+
the names of a declaration holding the whole request, those names never
|
|
746
|
+
reassigned (binding it is no read); else ``evaluated``, the node the
|
|
747
|
+
request is evaluated as."""
|
|
748
|
+
stmt = declarations.get(id(request))
|
|
749
|
+
names = ([stmt.name] if isinstance(stmt, VarDecl) else
|
|
750
|
+
list(stmt.names) if isinstance(stmt, TupleAssign) else [])
|
|
751
|
+
bindings = {index.decl_binding.get((id(stmt), name)) for name in names} - {None}
|
|
752
|
+
if stmt is None or not bindings or bindings & index.reassigned:
|
|
753
|
+
evaluated.annotations = {**(evaluated.annotations or {}), UNPINNED_ANNOTATION: marker}
|
|
754
|
+
return
|
|
755
|
+
for node in _nodes(program):
|
|
756
|
+
if isinstance(node, Subscript) and isinstance(node.object, Identifier):
|
|
757
|
+
reads = node.object
|
|
758
|
+
elif isinstance(node, Identifier):
|
|
759
|
+
reads = node
|
|
760
|
+
else:
|
|
761
|
+
continue
|
|
762
|
+
if index.refs.get(id(reads)) in bindings:
|
|
763
|
+
node.annotations = {**(node.annotations or {}), UNPINNED_ANNOTATION: marker}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def lower_no_data_requests(program: Program) -> Program:
|
|
767
|
+
"""Replace each request the support checker lowered
|
|
768
|
+
(``annotations[LOWERING_ANNOTATION]``) by its ``na``: an inert one's
|
|
769
|
+
plainly, and an unpinned one's so that its first read stops the run.
|
|
770
|
+
When the request is the whole value of a declaration whose names are
|
|
771
|
+
never reassigned, those names' reads stop it; otherwise evaluating the
|
|
772
|
+
request does. A request lowered onto pinned data (``DATA_LOWERINGS``)
|
|
773
|
+
stays, and the same reads stop the run only when its data is missing
|
|
774
|
+
when the run begins."""
|
|
775
|
+
read_footprint_deltas(program)
|
|
776
|
+
funcs = {s.name: s for s in program.body if isinstance(s, FuncDef)}
|
|
777
|
+
swaps: dict[int, ASTNode] = {}
|
|
778
|
+
unpinned: list[FuncCall] = []
|
|
779
|
+
backed: list[FuncCall] = []
|
|
780
|
+
for node in _nodes(program):
|
|
781
|
+
lowering = (node.annotations or {}).get(LOWERING_ANNOTATION)
|
|
782
|
+
if lowering is None or not isinstance(node, FuncCall):
|
|
783
|
+
continue
|
|
784
|
+
if lowering in DATA_LOWERINGS:
|
|
785
|
+
backed.append(node)
|
|
786
|
+
continue
|
|
787
|
+
if lowering == ABSENT_LOWERING:
|
|
788
|
+
node.annotations = {**node.annotations, UNPINNED_ANNOTATION: unpinned_message(node)}
|
|
789
|
+
continue
|
|
790
|
+
swaps[id(node)] = _na_of(node, funcs)
|
|
791
|
+
if lowering == "unpinned":
|
|
792
|
+
unpinned.append(node)
|
|
793
|
+
if unpinned or backed:
|
|
794
|
+
index = ScriptIndex(program)
|
|
795
|
+
declarations = {id(stmt.value): stmt for stmt in _nodes(program)
|
|
796
|
+
if isinstance(stmt, (VarDecl, TupleAssign))}
|
|
797
|
+
for request in unpinned:
|
|
798
|
+
_mark_reads(program, index, declarations, request, swaps[id(request)],
|
|
799
|
+
unpinned_message(request))
|
|
800
|
+
for request in backed:
|
|
801
|
+
ref = RequestRef()
|
|
802
|
+
request.annotations = {**(request.annotations or {}), REQUEST_REF_ANNOTATION: ref}
|
|
803
|
+
_mark_reads(program, index, declarations, request, request,
|
|
804
|
+
{"message": unpinned_message(request), "ref": ref})
|
|
805
|
+
if request.annotations.get(LOWERING_ANNOTATION) == RECORDED_LOWERING:
|
|
806
|
+
# The key's other parts are constants: the call keeps its
|
|
807
|
+
# symbol, the one part the run computes.
|
|
808
|
+
parts, _ = recorded_key(request)
|
|
809
|
+
symbol = parts.pop("symbol")
|
|
810
|
+
request.args, request.kwargs = [symbol], {}
|
|
811
|
+
notes = {k: v for k, v in request.annotations.items() if k != "call_arg_order"}
|
|
812
|
+
request.annotations = {**notes, RECORDED_KEY_ANNOTATION: parts}
|
|
813
|
+
if swaps:
|
|
814
|
+
replace_nodes(program, swaps)
|
|
815
|
+
return program
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def pass_warning(node: ASTNode, message: str, hint: str) -> tuple:
|
|
819
|
+
"""A warning of a pass between the support checker and the analyzer,
|
|
820
|
+
held on the AST as plain data (the analyzer makes it a ``Diagnostic``:
|
|
821
|
+
AST walkers recurse into annotation values, and an enum cycles)."""
|
|
822
|
+
return (message, hint, node.loc or SourceLocation(file="<input>", line=1, col=1, end_col=1))
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def unpin_requests(program: Program, reasons: dict[int, str]) -> None:
|
|
826
|
+
"""Give each request ``reasons`` names (by id: a request of another
|
|
827
|
+
symbol whose symbol registration cannot compute before the first bar)
|
|
828
|
+
the lowering it had before it read a feed: its ``na``, whose reads stop
|
|
829
|
+
the run with the request named; or, for one whose symbol can select the
|
|
830
|
+
chart's (``CHART_FALLBACK_ANNOTATION``), the chart's bars, with the
|
|
831
|
+
warning it had. Each is reported with its reason."""
|
|
832
|
+
from .security_contexts import PASS_WARNINGS_ANNOTATION
|
|
833
|
+
|
|
834
|
+
funcs = {s.name: s for s in program.body if isinstance(s, FuncDef)}
|
|
835
|
+
requests = {id(node): node for node in _nodes(program) if id(node) in reasons}
|
|
836
|
+
refs = {id((node.annotations or {}).get(REQUEST_REF_ANNOTATION)) for node in requests.values()}
|
|
837
|
+
chart_refs = {id(node.annotations.get(REQUEST_REF_ANNOTATION)) for node in requests.values()
|
|
838
|
+
if (node.annotations or {}).get(CHART_FALLBACK_ANNOTATION)}
|
|
839
|
+
swaps: dict[int, ASTNode] = {}
|
|
840
|
+
warnings = []
|
|
841
|
+
for request_id, request in list(requests.items()):
|
|
842
|
+
if (request.annotations or {}).get(CHART_FALLBACK_ANNOTATION):
|
|
843
|
+
request.annotations = {
|
|
844
|
+
k: v for k, v in request.annotations.items()
|
|
845
|
+
if k not in (LOWERING_ANNOTATION, REQUEST_REF_ANNOTATION, UNPINNED_ANNOTATION,
|
|
846
|
+
CHART_FALLBACK_ANNOTATION, FEED_WARNING_ANNOTATION)}
|
|
847
|
+
warnings.append(pass_warning(
|
|
848
|
+
request,
|
|
849
|
+
f"{spell_call(request)}: request.security symbol can select an alternate "
|
|
850
|
+
"symbol, but PineForge always loads the current chart symbol.",
|
|
851
|
+
f"It reads no feed of another symbol: {reasons[request_id]}. Every reachable "
|
|
852
|
+
"symbol value must resolve to syminfo.tickerid or syminfo.ticker for exact "
|
|
853
|
+
"results."))
|
|
854
|
+
continue
|
|
855
|
+
lowered = _na_of(request, funcs)
|
|
856
|
+
marker = (request.annotations or {}).get(UNPINNED_ANNOTATION)
|
|
857
|
+
if isinstance(marker, dict):
|
|
858
|
+
lowered.annotations = {**(lowered.annotations or {}),
|
|
859
|
+
UNPINNED_ANNOTATION: marker["message"]}
|
|
860
|
+
swaps[request_id] = lowered
|
|
861
|
+
warnings.append(pass_warning(
|
|
862
|
+
request,
|
|
863
|
+
f"{spell_call(request)}: no data is pinned for this request; the run stops with "
|
|
864
|
+
"an error where its value is read.",
|
|
865
|
+
f"PineForge reads another symbol's feed only for a symbol and timeframe "
|
|
866
|
+
f"registration computes before the first bar; {reasons[request_id]}."))
|
|
867
|
+
for node in _nodes(program):
|
|
868
|
+
marker = (node.annotations or {}).get(UNPINNED_ANNOTATION)
|
|
869
|
+
if isinstance(marker, dict) and id(marker["ref"]) in chart_refs:
|
|
870
|
+
node.annotations = {k: v for k, v in node.annotations.items()
|
|
871
|
+
if k != UNPINNED_ANNOTATION}
|
|
872
|
+
elif isinstance(marker, dict) and id(marker["ref"]) in refs:
|
|
873
|
+
node.annotations = {**node.annotations, UNPINNED_ANNOTATION: marker["message"]}
|
|
874
|
+
if swaps:
|
|
875
|
+
replace_nodes(program, swaps)
|
|
876
|
+
notes = program.annotations = dict(program.annotations or {})
|
|
877
|
+
notes[PASS_WARNINGS_ANNOTATION] = [*notes.get(PASS_WARNINGS_ANNOTATION, ()), *warnings]
|