@pineforge/codegen-pyodide 0.10.4 → 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.
Files changed (50) hide show
  1. package/README.md +16 -16
  2. package/glue.py +24 -16
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +125 -34
  5. package/pineforge_codegen/analyzer/__init__.py +2 -0
  6. package/pineforge_codegen/analyzer/base.py +754 -76
  7. package/pineforge_codegen/analyzer/call_handlers.py +260 -40
  8. package/pineforge_codegen/analyzer/contracts.py +37 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +30 -4
  10. package/pineforge_codegen/analyzer/tables.py +49 -8
  11. package/pineforge_codegen/analyzer/types.py +33 -1
  12. package/pineforge_codegen/ast_nodes.py +32 -1
  13. package/pineforge_codegen/block_locals.py +185 -0
  14. package/pineforge_codegen/builtin_keywords.py +42 -0
  15. package/pineforge_codegen/codegen/base.py +896 -156
  16. package/pineforge_codegen/codegen/constant_fold.py +131 -0
  17. package/pineforge_codegen/codegen/drawing.py +221 -79
  18. package/pineforge_codegen/codegen/emit_top.py +946 -213
  19. package/pineforge_codegen/codegen/helpers.py +435 -14
  20. package/pineforge_codegen/codegen/host_members.py +162 -0
  21. package/pineforge_codegen/codegen/input.py +252 -85
  22. package/pineforge_codegen/codegen/security.py +4372 -377
  23. package/pineforge_codegen/codegen/session_market.py +71 -0
  24. package/pineforge_codegen/codegen/ta.py +1188 -100
  25. package/pineforge_codegen/codegen/tables.py +193 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +1882 -78
  28. package/pineforge_codegen/codegen/visit_call.py +920 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +738 -57
  30. package/pineforge_codegen/codegen/visit_stmt.py +595 -49
  31. package/pineforge_codegen/external_requests.py +877 -0
  32. package/pineforge_codegen/lexer.py +104 -22
  33. package/pineforge_codegen/library_inline.py +1304 -0
  34. package/pineforge_codegen/library_modules.py +126 -0
  35. package/pineforge_codegen/library_v5.py +683 -0
  36. package/pineforge_codegen/limits.py +138 -0
  37. package/pineforge_codegen/method_binding.py +33 -0
  38. package/pineforge_codegen/parser.py +384 -68
  39. package/pineforge_codegen/pine_libraries.py +266 -0
  40. package/pineforge_codegen/pine_spelling.py +216 -0
  41. package/pineforge_codegen/pragmas.py +64 -10
  42. package/pineforge_codegen/security_contexts.py +1585 -0
  43. package/pineforge_codegen/session_reads.py +84 -0
  44. package/pineforge_codegen/signatures.py +48 -23
  45. package/pineforge_codegen/support_checker.py +1106 -85
  46. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  47. package/release.json +2 -2
  48. package/tables.json +23 -21
  49. package/transpile.worker.mjs +24 -16
  50. package/pineforge_codegen-0.10.4.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]