@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.
Files changed (51) 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 +767 -77
  7. package/pineforge_codegen/analyzer/call_handlers.py +268 -42
  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 +907 -159
  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 +961 -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 +1198 -101
  25. package/pineforge_codegen/codegen/tables.py +205 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +2055 -62
  28. package/pineforge_codegen/codegen/visit_call.py +929 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +765 -63
  30. package/pineforge_codegen/codegen/visit_stmt.py +610 -52
  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/symbols.py +4 -2
  47. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  48. package/release.json +2 -2
  49. package/tables.json +23 -21
  50. package/transpile.worker.mjs +24 -16
  51. package/pineforge_codegen-0.10.3.tar.gz +0 -0
@@ -0,0 +1,1585 @@
1
+ """One ``request.security`` context per symbol and timeframe reaching a helper.
2
+
3
+ A request's context -- the symbol and the timeframe it aggregates -- is fixed
4
+ before the first bar: ``register_security_eval`` runs before ``on_bar``. When
5
+ a helper receives them as parameters, the context is the value of those
6
+ parameters on each call path, and a path may pass a parameter on through
7
+ further helpers::
8
+
9
+ g(sym, tf) => request.security(sym, tf, ta.sma(close, 3))
10
+ h(sym, tf) => g(sym, tf)
11
+ a = h(syminfo.tickerid, tfA)
12
+ b = h(syminfo.tickerid, tfB)
13
+
14
+ The analyzer resolves a helper's timeframe from the arguments of the helper's
15
+ own call sites only (``Analyzer._check_mixed_callsite_security_tf``), so the
16
+ codegen registered every such request on the chart's timeframe
17
+ (``input_tf_``) with no diagnostic, and ``h(ticker.heikinashi(...), tf)``
18
+ read plain candles. This pass resolves the symbol and timeframe of every
19
+ request whose context depends on a helper's parameters or locals along every
20
+ call path from the top level, with each parameter's value read in its
21
+ caller's scope, down to expressions of globals, inputs and built-ins, which
22
+ registration can evaluate:
23
+
24
+ * one context on every path: the request is annotated with it
25
+ (``annotations["pf_security_context"]``, read by the analyzer);
26
+ * several: each helper on the paths is copied once per distinct context it
27
+ leads to (``h__pfctx1``, ...), each copy calling the copies of its callees
28
+ that lead to its own contexts, and each request copy annotated with its
29
+ context;
30
+ * a value no registration can compute (a series, a reassigned local, a user
31
+ call, too many paths) is refused, naming the request, the parameter and the
32
+ path.
33
+
34
+ A request whose timeframe and symbol are bare parameters, bound at every call
35
+ site of its helper to a global expression, stays with the analyzer's call-site
36
+ clones (``callsite_idx``), which emit it as every earlier build did. A helper
37
+ no top-level statement reaches is marked dead: its requests never run and
38
+ keep the chart timeframe.
39
+
40
+ The helper's parameters a request's payload reads are part of its context
41
+ too: the payload is evaluated on the requested bars, where the argument a
42
+ parameter is bound to is recomputed::
43
+
44
+ nr(_s, _tf, _e) => request.security(_s, _tf, _e[1], lookahead = barmerge.lookahead_on)
45
+ reso(_x, _r) => nr(syminfo.tickerid, _r, _x)
46
+ a = reso(fastMa, "60")
47
+ b = reso(slowMa, "60")
48
+
49
+ The evaluator never bound such a parameter: ``_e[1]`` read a member nothing
50
+ pushed (``na`` on every bar), a bare ``_e`` was refused as an unknown
51
+ variable, and ``f(10) + f(20)`` with ``ta.sma(close, len)`` shared one
52
+ evaluator built from the first call's length. Each such parameter is
53
+ resolved like the symbol and the timeframe, bar series and user calls
54
+ allowed, the requests are copied once per distinct value, and each copy's
55
+ payload reads the value in the parameter's place (``fastMa[1]``; an untitled
56
+ input carries its declaration's key as its title). A length or history index
57
+ every top-level call passes one value keeps the analyzer's single evaluator;
58
+ its call-site clones for differing timeframes built every clone from the
59
+ first call's length. Only a value the request builder lowers on the
60
+ requested bars in the read's place is put in (``_Lowered``); any other keeps
61
+ the earlier lowering, never a refusal: a reassigned or ``var`` name, a loop
62
+ variable, a name the helper declares, a user call other than a global's
63
+ value the builder inlines whole (``_inlined_function``), a global declared
64
+ after the helper (read on the chart's terms there), a history object other
65
+ than a bar or price series, a ``ta.*`` call, an ``input.source``, an inline
66
+ operator expression or a global holding a numeric one or such a call, a
67
+ global under a builtin rendered on the chart's terms.
68
+
69
+ A request of another symbol that reads that symbol's pinned feed
70
+ (``external_requests``: the support checker's ``feed`` lowering) is keyed by
71
+ its symbol as well, so this pass owns every one whose symbol or timeframe
72
+ reaches it through a helper's parameters, the analyzer's call-site clones
73
+ included (they tell call sites apart by their timeframe only)::
74
+
75
+ f_htfPack(sym, tf) => request.security(sym, tf, f_packConfirmed())
76
+ f_symbolState(sym, tf) => f_htfPack(sym, tf)
77
+ f_tfRegime(sym) => [f_symbolState(sym, mainTf), f_symbolState(sym, confirmTf)]
78
+ [v1, v2] = f_tfRegime(vixSymbol)
79
+ [d1, d2] = f_tfRegime(dxySymbol)
80
+
81
+ is six contexts. Registration reads a symbol before the first bar, so one
82
+ that is not a value registration computes there on some call path (a
83
+ series, a reassigned name, a user call: ``ScriptIndex.registration_value``)
84
+ does not refuse the script: that request keeps the lowering it had before
85
+ it read a feed, a deferred refusal whose first read stops the run
86
+ (``external_requests.unpin_requests``), with a warning naming the path.
87
+ """
88
+
89
+ from __future__ import annotations
90
+
91
+ import copy
92
+ from dataclasses import replace
93
+
94
+ from .ast_nodes import (
95
+ ArgOrder, ASTNode, Assignment, BinOp, BoolLiteral, ColorLiteral, ExprStmt, ForInStmt,
96
+ ForStmt, FuncCall, FuncDef, Identifier, IfStmt, MemberAccess, MethodDef,
97
+ NaLiteral, NumberLiteral, Program, StringLiteral, Subscript, SwitchStmt,
98
+ Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl, WhileStmt,
99
+ )
100
+ from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation
101
+ from .pine_spelling import input_binding_names
102
+
103
+
104
+ CONTEXT_ANNOTATION = "pf_security_context"
105
+ DEAD_ANNOTATION = "pf_security_dead"
106
+ # On a payload's read of a parameter of a helper no top-level statement
107
+ # reaches: the codegen does not warn that it reads na.
108
+ UNREACHED_ANNOTATION = "pf_security_unreached"
109
+ # On the Program: the warnings of the passes that run between the support
110
+ # checker and the analyzer, which the analyzer reports as its own.
111
+ PASS_WARNINGS_ANNOTATION = "pf_pass_warnings"
112
+ # ``external_requests.LOWERING_ANNOTATION`` and its ``feed`` lowering, spelled
113
+ # here too: that module imports this one.
114
+ _LOWERING_ANNOTATION = "pf_request_lowering"
115
+ _FEED_LOWERING = "feed"
116
+ _FEED_WARNING_ANNOTATION = "pf_request_feed_warning"
117
+ # On a global's read a payload parameter's value put in a helper's copy:
118
+ # the global may be declared after the helper, where the analyzer binds
119
+ # no name, and the codegen reads it as the global all the same.
120
+ GLOBAL_ANNOTATION = "pf_security_global"
121
+ _REQUEST_FUNCS = ("security", "security_lower_tf")
122
+ # Instances (a helper under one set of parameter values) the pass may build
123
+ # before it refuses the script: diamond-shaped helper graphs multiply paths.
124
+ _MAX_INSTANCES = 512
125
+ # Nodes a payload parameter's value may hold: a value doubling through nested
126
+ # helpers (``h(x) => g(x + x)``) keeps the earlier lowering.
127
+ _MAX_PAYLOAD_NODES = 256
128
+ _LITERALS = (StringLiteral, NumberLiteral, BoolLiteral, NaLiteral, ColorLiteral)
129
+ # The chart's own symbol strings, and the inputs, registration reads
130
+ # (``ScriptIndex.registration_value``).
131
+ _REGISTRATION_MEMBERS = frozenset({
132
+ ("syminfo", "tickerid"), ("syminfo", "ticker"), ("syminfo", "prefix"),
133
+ ("syminfo", "currency"), ("syminfo", "basecurrency"),
134
+ })
135
+ _REGISTRATION_INPUTS = frozenset({"symbol", "string", "bool"})
136
+ # Built-in series a context cannot be computed from before the first bar.
137
+ _BAR_SERIES = frozenset({
138
+ "open", "high", "low", "close", "volume", "hl2", "hlc3", "ohlc4", "hlcc4",
139
+ "time", "time_close", "time_tradingday", "timenow", "bar_index", "last_bar_index",
140
+ })
141
+
142
+
143
+ class _Unresolvable(Exception):
144
+ """A context value no registration can compute."""
145
+
146
+
147
+ class _Fallback(Exception):
148
+ """Requests whose payload parameters keep the earlier lowering."""
149
+
150
+ def __init__(self, requests) -> None:
151
+ super().__init__()
152
+ self.requests = set(requests)
153
+
154
+
155
+ class _FeedFallback(Exception):
156
+ """Requests of another symbol that read no feed: request id -> why."""
157
+
158
+ def __init__(self, reasons: dict[int, str]) -> None:
159
+ super().__init__()
160
+ self.reasons = dict(reasons)
161
+
162
+
163
+ def _is_feed(request) -> bool:
164
+ """``request`` reads another symbol's pinned feed."""
165
+ return (request.annotations or {}).get(_LOWERING_ANNOTATION) == _FEED_LOWERING
166
+
167
+
168
+ def reads_bar_series(expr) -> str | None:
169
+ """The built-in bar series ``expr`` reads, if any: a context computed
170
+ from it has no value before the first bar."""
171
+ for node in _walk(expr):
172
+ if isinstance(node, Identifier) and node.name in _BAR_SERIES:
173
+ return node.name
174
+ return None
175
+
176
+
177
+ def _request_name(node) -> str | None:
178
+ if (isinstance(node, FuncCall) and isinstance(node.callee, MemberAccess)
179
+ and isinstance(node.callee.object, Identifier)
180
+ and node.callee.object.name == "request"
181
+ and node.callee.member in _REQUEST_FUNCS):
182
+ return node.callee.member
183
+ return None
184
+
185
+
186
+ def ticker_symbol_arg(node: FuncCall):
187
+ """The symbol argument of ``ticker.inherit`` / ``standard`` /
188
+ ``heikinashi``: ``ticker.inherit(from_tickerid, symbol)`` names the
189
+ symbol second (a one-argument spelling keeps its only one)."""
190
+ if "symbol" in node.kwargs:
191
+ return node.kwargs["symbol"]
192
+ if (isinstance(node.callee, MemberAccess) and node.callee.member == "inherit"
193
+ and len(node.args) > 1):
194
+ return node.args[1]
195
+ return node.args[0] if node.args else node.kwargs.get("from_tickerid")
196
+
197
+
198
+ def _request_args(node: FuncCall) -> tuple[object, object]:
199
+ symbol = node.args[0] if node.args else node.kwargs.get("symbol")
200
+ tf = node.args[1] if len(node.args) > 1 else node.kwargs.get("timeframe")
201
+ return symbol, tf
202
+
203
+
204
+ def _request_payload(node: FuncCall):
205
+ """The expression a request evaluates on the requested bars."""
206
+ return node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
207
+
208
+
209
+ def _children(node):
210
+ for key, value in vars(node).items():
211
+ if key in ("loc", "annotations"):
212
+ continue
213
+ yield value
214
+
215
+
216
+ def _walk(value):
217
+ """Every AST node below ``value``, depth first, in source order."""
218
+ stack = [value]
219
+ while stack:
220
+ item = stack.pop()
221
+ if isinstance(item, ASTNode):
222
+ yield item
223
+ stack.extend(reversed(list(_children(item))))
224
+ elif isinstance(item, (list, tuple)):
225
+ stack.extend(reversed(item))
226
+ elif isinstance(item, dict):
227
+ stack.extend(reversed(list(item.values())))
228
+
229
+
230
+ def replace_nodes(root, swaps: dict[int, ASTNode]) -> None:
231
+ """Put ``swaps[id(node)]`` in the place of each such node (a call's
232
+ argument order too)."""
233
+ def fix(value):
234
+ if isinstance(value, ASTNode):
235
+ if id(value) in swaps:
236
+ return swaps[id(value)]
237
+ for key, item in list(vars(value).items()):
238
+ if key == "loc":
239
+ continue
240
+ if key == "annotations":
241
+ order = (item or {}).get("call_arg_order")
242
+ if isinstance(order, ArgOrder) and any(id(n) in swaps for n in order):
243
+ item["call_arg_order"] = ArgOrder(swaps.get(id(n), n) for n in order)
244
+ continue
245
+ new = fix(item)
246
+ if new is not item:
247
+ setattr(value, key, new)
248
+ return value
249
+ if isinstance(value, list):
250
+ for i, item in enumerate(value):
251
+ new = fix(item)
252
+ if new is not item:
253
+ value[i] = new
254
+ return value
255
+ if isinstance(value, tuple):
256
+ items = tuple(fix(item) for item in value)
257
+ return items if any(a is not b for a, b in zip(items, value)) else value
258
+ if isinstance(value, dict):
259
+ for key, item in list(value.items()):
260
+ new = fix(item)
261
+ if new is not item:
262
+ value[key] = new
263
+ return value
264
+ return value
265
+
266
+ fix(root)
267
+
268
+
269
+ def context_key(value):
270
+ """A hashable spelling of an AST value, blind to locations."""
271
+ if isinstance(value, ASTNode):
272
+ return (type(value).__name__,) + tuple(
273
+ (key, context_key(item)) for key, item in sorted(vars(value).items())
274
+ if key not in ("loc", "annotations"))
275
+ if isinstance(value, (list, tuple)):
276
+ return tuple(context_key(item) for item in value)
277
+ if isinstance(value, dict):
278
+ return tuple(sorted((key, context_key(item)) for key, item in value.items()))
279
+ return value
280
+
281
+
282
+ def _spell(node) -> str:
283
+ """A short Pine spelling of a resolved context value, for messages."""
284
+ if isinstance(node, StringLiteral):
285
+ return f'"{node.value}"'
286
+ if isinstance(node, Identifier):
287
+ return node.name
288
+ if isinstance(node, MemberAccess):
289
+ return f"{_spell(node.object)}.{node.member}"
290
+ if isinstance(node, FuncCall):
291
+ args = [_spell(a) for a in node.args]
292
+ args += [f"{k}={_spell(v)}" for k, v in node.kwargs.items()]
293
+ return f"{_spell(node.callee)}({', '.join(args)})"
294
+ if isinstance(node, NumberLiteral):
295
+ return str(node.value)
296
+ if isinstance(node, BoolLiteral):
297
+ return "true" if node.value else "false"
298
+ if isinstance(node, NaLiteral):
299
+ return "na"
300
+ if isinstance(node, BinOp):
301
+ return f"{_spell(node.left)} {node.op} {_spell(node.right)}"
302
+ if isinstance(node, UnaryOp):
303
+ return f"{node.op} {_spell(node.operand)}" if node.op == "not" else (
304
+ f"{node.op}{_spell(node.operand)}")
305
+ if isinstance(node, Ternary):
306
+ return (f"{_spell(node.condition)} ? {_spell(node.true_val)} : "
307
+ f"{_spell(node.false_val)}")
308
+ if isinstance(node, Subscript):
309
+ return f"{_spell(node.object)}[{_spell(node.index)}]"
310
+ return type(node).__name__
311
+
312
+
313
+ class ScriptIndex:
314
+ """The lexical bindings, helpers and calls of a program."""
315
+
316
+ def __init__(self, program: Program) -> None:
317
+ self.program = program
318
+ self.funcs: dict[str, FuncDef] = {}
319
+ # Names defined more than once: Pine overloads a function by its
320
+ # parameters, which this pass does not tell apart.
321
+ self.overloaded: set[str] = set()
322
+ for stmt in program.body:
323
+ if isinstance(stmt, FuncDef):
324
+ if stmt.name in self.funcs:
325
+ self.overloaded.add(stmt.name)
326
+ self.funcs[stmt.name] = stmt
327
+ # Identifier id -> its binding: ("global", name) for an unnested
328
+ # top-level declaration, ("param", func, name), ("local", decl id)
329
+ # for a declaration in a callable or a top-level block, ("bound",
330
+ # node id, name) for a loop variable or a tuple element. Built-ins
331
+ # and callables have none.
332
+ self.refs: dict[int, tuple] = {}
333
+ self.decls: dict[tuple, VarDecl] = {}
334
+ # Declaration (VarDecl, or TupleAssign and name) id -> the binding
335
+ # its name's reads resolve to in its block.
336
+ self.decl_binding: dict[tuple, tuple] = {}
337
+ self.unstable: set[tuple] = set() # reassigned, var or varip
338
+ self.reassigned: set[tuple] = set()
339
+ # FuncCall id -> the callable whose body holds it (None: top level).
340
+ self.owner: dict[int, str | None] = {}
341
+ self.calls: dict[str, list[FuncCall]] = {name: [] for name in self.funcs}
342
+ self.call_ids: set[int] = set() # the calls in ``calls``
343
+ self.requests: dict[str | None, list[FuncCall]] = {}
344
+ self._index()
345
+ # Global input call id -> the declaration naming it (its key when
346
+ # untitled).
347
+ self.input_names = input_binding_names(program.body)
348
+ # Every name a top-level statement declares.
349
+ self.program_names = {stmt.name for stmt in program.body
350
+ if isinstance(stmt, VarDecl) and stmt.name}
351
+ self.program_names.update(name for stmt in program.body
352
+ if isinstance(stmt, TupleAssign) for name in stmt.names)
353
+
354
+ def _index(self) -> None:
355
+ def resolve(name: str, scopes) -> tuple | None:
356
+ for scope in reversed(scopes):
357
+ if name in scope:
358
+ return scope[name]
359
+ return None
360
+
361
+ def block(stmts, scopes, owner, binders=None, top=False) -> None:
362
+ scope = dict(binders or {})
363
+ for stmt in stmts:
364
+ if isinstance(stmt, VarDecl) and stmt.name:
365
+ key = ("global", stmt.name) if top else ("local", id(stmt))
366
+ scope.setdefault(stmt.name, key)
367
+ self.decls.setdefault(key, stmt)
368
+ self.decl_binding[(id(stmt), stmt.name)] = scope[stmt.name]
369
+ if stmt.is_var or stmt.is_varip:
370
+ self.unstable.add(key)
371
+ elif isinstance(stmt, TupleAssign):
372
+ for name in stmt.names:
373
+ scope.setdefault(
374
+ name, ("global", name) if top else ("bound", id(stmt), name))
375
+ self.decl_binding[(id(stmt), name)] = scope[name]
376
+ nested = (*scopes, scope)
377
+ for stmt in stmts:
378
+ walk(stmt, nested, owner)
379
+
380
+ def walk(value, scopes, owner) -> None:
381
+ if isinstance(value, (list, tuple)):
382
+ for item in value:
383
+ walk(item, scopes, owner)
384
+ return
385
+ if isinstance(value, dict):
386
+ for item in value.values():
387
+ walk(item, scopes, owner)
388
+ return
389
+ if not isinstance(value, ASTNode):
390
+ return
391
+ node = value
392
+ if isinstance(node, Identifier):
393
+ binding = resolve(node.name, scopes)
394
+ if binding is not None:
395
+ self.refs[id(node)] = binding
396
+ return
397
+ if isinstance(node, FuncCall):
398
+ self.owner[id(node)] = owner
399
+ if (isinstance(node.callee, Identifier) and node.callee.name in self.funcs
400
+ and resolve(node.callee.name, scopes) is None):
401
+ self.calls[node.callee.name].append(node)
402
+ self.call_ids.add(id(node))
403
+ if _request_name(node):
404
+ self.requests.setdefault(owner, []).append(node)
405
+ if isinstance(node, Assignment):
406
+ if isinstance(node.target, Identifier):
407
+ binding = resolve(node.target.name, scopes)
408
+ if binding is not None:
409
+ self.unstable.add(binding)
410
+ self.reassigned.add(binding)
411
+ if isinstance(node, (FuncDef, MethodDef)):
412
+ name = node.name if isinstance(node, FuncDef) else None
413
+ params = {p: ("param", node.name, p) for p in node.params}
414
+ block(node.body, scopes, name if name in self.funcs else f"method {node.name}",
415
+ params)
416
+ return
417
+ if isinstance(node, IfStmt):
418
+ walk(node.condition, scopes, owner)
419
+ block(node.body, scopes, owner)
420
+ block(node.else_body, scopes, owner)
421
+ return
422
+ if isinstance(node, ForStmt):
423
+ walk([node.start, node.end, node.step], scopes, owner)
424
+ block(node.body, scopes, owner, {node.var: ("bound", id(node), node.var)})
425
+ return
426
+ if isinstance(node, ForInStmt):
427
+ walk(node.iterable, scopes, owner)
428
+ names = [node.var] if node.var else list(node.vars or ())
429
+ block(node.body, scopes, owner,
430
+ {n: ("bound", id(node), n) for n in names})
431
+ return
432
+ if isinstance(node, WhileStmt):
433
+ walk(node.condition, scopes, owner)
434
+ block(node.body, scopes, owner)
435
+ return
436
+ if isinstance(node, SwitchStmt):
437
+ walk(node.expr, scopes, owner)
438
+ for case_expr, body in node.cases:
439
+ walk(case_expr, scopes, owner)
440
+ block(body, scopes, owner)
441
+ block(node.default_body, scopes, owner)
442
+ return
443
+ for child in _children(node):
444
+ walk(child, scopes, owner)
445
+
446
+ block(self.program.body, (), None, top=True)
447
+
448
+ # -- scopes --------------------------------------------------------------
449
+
450
+ def depends_on_scope(self, expr) -> bool:
451
+ """``expr`` reads a parameter, a local or a bound name."""
452
+ return any(isinstance(n, Identifier) and self.refs.get(id(n), ("global",))[0]
453
+ in ("param", "local", "bound") for n in _walk(expr))
454
+
455
+ def param_arg(self, func: str, call: FuncCall, param: str):
456
+ """The argument ``call`` binds to ``param`` of ``func`` (its default
457
+ when omitted), or None."""
458
+ fdef = self.funcs[func]
459
+ if param in call.kwargs:
460
+ return call.kwargs[param]
461
+ index = fdef.params.index(param)
462
+ if index < len(call.args):
463
+ return call.args[index]
464
+ defaults = (fdef.annotations or {}).get("param_defaults") or []
465
+ return defaults[index] if index < len(defaults) else None
466
+
467
+ # -- values --------------------------------------------------------------
468
+
469
+ def resolve(self, expr, env: dict | None, seen: frozenset = frozenset()):
470
+ """``expr``, read in a scope whose parameters ``env`` binds, as an
471
+ expression of globals, inputs, literals and built-ins."""
472
+ if isinstance(expr, _LITERALS):
473
+ return expr
474
+ if isinstance(expr, Identifier):
475
+ binding = self.refs.get(id(expr))
476
+ if binding is None and expr.name in _BAR_SERIES:
477
+ raise _Unresolvable(f"it reads '{expr.name}', a series")
478
+ if binding is None or binding[0] == "global":
479
+ return expr
480
+ if binding[0] == "param":
481
+ if env is None or binding[2] not in env:
482
+ raise _Unresolvable(f"parameter '{binding[2]}' has no argument")
483
+ arg, caller_env = env[binding[2]]
484
+ if arg is None:
485
+ raise _Unresolvable(f"parameter '{binding[2]}' has no argument")
486
+ return self.resolve(arg, caller_env, seen)
487
+ if binding[0] == "local" and binding not in self.unstable and binding not in seen:
488
+ decl = self.decls[binding]
489
+ if decl.value is not None:
490
+ return self.resolve(decl.value, env, seen | {binding})
491
+ raise _Unresolvable(f"'{expr.name}' is not a value fixed before the first bar")
492
+ if isinstance(expr, MemberAccess):
493
+ if (isinstance(expr.object, Identifier)
494
+ and self.refs.get(id(expr.object), ("global",))[0] == "global"):
495
+ return expr
496
+ raise _Unresolvable(f"'{_spell(expr)}' is not a value fixed before the first bar")
497
+ if isinstance(expr, FuncCall):
498
+ if isinstance(expr.callee, Identifier) and expr.callee.name in self.funcs:
499
+ raise _Unresolvable(f"the user function call {expr.callee.name}(...)")
500
+ if not (isinstance(expr.callee, Identifier) or (
501
+ isinstance(expr.callee, MemberAccess)
502
+ and isinstance(expr.callee.object, Identifier)
503
+ and id(expr.callee.object) not in self.refs)):
504
+ raise _Unresolvable(f"the method call {_spell(expr)}")
505
+ args = [self.resolve(a, env, seen) for a in expr.args]
506
+ kwargs = {k: self.resolve(v, env, seen) for k, v in expr.kwargs.items()}
507
+ if all(a is b for a, b in zip(args, expr.args)) and all(
508
+ kwargs[k] is expr.kwargs[k] for k in kwargs):
509
+ return expr
510
+ return replace(expr, args=args, kwargs=kwargs)
511
+ if isinstance(expr, Ternary):
512
+ parts = [self.resolve(p, env, seen)
513
+ for p in (expr.condition, expr.true_val, expr.false_val)]
514
+ if all(a is b for a, b in zip(parts, (expr.condition, expr.true_val,
515
+ expr.false_val))):
516
+ return expr
517
+ return replace(expr, condition=parts[0], true_val=parts[1], false_val=parts[2])
518
+ if isinstance(expr, BinOp):
519
+ left, right = self.resolve(expr.left, env, seen), self.resolve(expr.right, env, seen)
520
+ if left is expr.left and right is expr.right:
521
+ return expr
522
+ return replace(expr, left=left, right=right)
523
+ if isinstance(expr, UnaryOp):
524
+ operand = self.resolve(expr.operand, env, seen)
525
+ return expr if operand is expr.operand else replace(expr, operand=operand)
526
+ raise _Unresolvable(f"a {type(expr).__name__} is not a value fixed before the first bar")
527
+
528
+ def resolve_payload(self, expr, env: dict | None, seen: frozenset = frozenset()):
529
+ """A fresh copy of ``expr``, read in a scope whose parameters ``env``
530
+ binds, with each parameter and local replaced by its value: an
531
+ expression of globals, literals and built-ins, bar series and user
532
+ calls included, which a payload recomputes on the requested bars."""
533
+ swaps = {}
534
+ for node in _walk(expr):
535
+ if isinstance(node, FuncCall) and _request_name(node):
536
+ raise _Unresolvable(f"it holds a request.{_request_name(node)} call")
537
+ if not isinstance(node, Identifier):
538
+ continue
539
+ binding = self.refs.get(id(node))
540
+ if binding is None:
541
+ continue
542
+ if binding in self.unstable:
543
+ raise _Unresolvable(f"'{node.name}' is reassigned or declared var")
544
+ if binding[0] == "global":
545
+ continue
546
+ if binding[0] == "param":
547
+ arg, caller_env = (env or {}).get(binding[2], (None, None))
548
+ if arg is None:
549
+ raise _Unresolvable(f"parameter '{binding[2]}' has no argument")
550
+ swaps[id(node)] = self.resolve_payload(arg, caller_env, seen)
551
+ elif (binding[0] == "local" and binding not in seen
552
+ and self.decls[binding].value is not None):
553
+ swaps[id(node)] = self.resolve_payload(
554
+ self.decls[binding].value, env, seen | {binding})
555
+ else:
556
+ raise _Unresolvable(f"'{node.name}' is not a value the requested bars recompute")
557
+ if id(expr) in swaps:
558
+ return swaps[id(expr)]
559
+ memo: dict = {}
560
+ fresh = copy.deepcopy(expr, memo)
561
+ replace_nodes(fresh, {id(memo[key]): value for key, value in swaps.items()})
562
+ for node in _walk(expr):
563
+ if (isinstance(node, Identifier)
564
+ and self.refs.get(id(node), ("",))[0] == "global"):
565
+ read = memo[id(node)]
566
+ read.annotations = {**(read.annotations or {}), GLOBAL_ANNOTATION: True}
567
+ if sum(1 for _ in _walk(fresh)) > _MAX_PAYLOAD_NODES:
568
+ raise _Unresolvable("its value is too large")
569
+ # An untitled input is keyed by the declaration holding it, which its
570
+ # copy leaves: the copy carries the key as its title.
571
+ for node in _walk(expr):
572
+ name = self.input_names.get(id(node))
573
+ if name is None or "title" in node.kwargs or len(node.args) > 1:
574
+ continue
575
+ call = memo[id(node)]
576
+ title = StringLiteral(value=name)
577
+ call.kwargs = {**call.kwargs, "title": title}
578
+ order = (call.annotations or {}).get("call_arg_order")
579
+ if isinstance(order, ArgOrder):
580
+ call.annotations = {**call.annotations,
581
+ "call_arg_order": ArgOrder([*order, title])}
582
+ return fresh
583
+
584
+ def registers_timeframe(self, expr, seen: frozenset = frozenset()) -> bool:
585
+ """``expr``, a request's timeframe, has the run's value where
586
+ registration reads it, before the first bar: it reads no bar series,
587
+ and no name the script reassigns or declares ``var`` (there it holds
588
+ its type's default), through globals too."""
589
+ if reads_bar_series(expr) is not None:
590
+ return False
591
+ for node in _walk(expr):
592
+ if not isinstance(node, Identifier):
593
+ continue
594
+ binding = self.refs.get(id(node))
595
+ if binding is None or binding in seen:
596
+ continue
597
+ if binding[0] != "global" or binding in self.unstable:
598
+ return False
599
+ decl = self.decls.get(binding)
600
+ if decl is not None and decl.value is not None and not self.registers_timeframe(
601
+ decl.value, seen | {binding}):
602
+ return False
603
+ return True
604
+
605
+ def registration_value(self, expr, seen: frozenset = frozenset()) -> bool:
606
+ """``expr`` is a value registration computes before the first bar:
607
+ literals, inputs (TradingView takes constant arguments), the chart's
608
+ own symbol strings, and operators and ternaries over them, read
609
+ through globals never reassigned. A request of another symbol is
610
+ registered with its symbol string and ``ignore_invalid_symbol`` so."""
611
+ if isinstance(expr, (StringLiteral, NumberLiteral, BoolLiteral)):
612
+ return True
613
+ if isinstance(expr, Identifier):
614
+ binding = self.refs.get(id(expr))
615
+ if (binding is None or binding[0] != "global" or binding in self.unstable
616
+ or binding in seen):
617
+ return False
618
+ decl = self.decls.get(binding)
619
+ return (decl is not None and decl.value is not None
620
+ and self.registration_value(decl.value, seen | {binding}))
621
+ if isinstance(expr, MemberAccess):
622
+ return (isinstance(expr.object, Identifier) and id(expr.object) not in self.refs
623
+ and (expr.object.name, expr.member) in _REGISTRATION_MEMBERS)
624
+ if isinstance(expr, FuncCall):
625
+ callee = expr.callee
626
+ if isinstance(callee, Identifier):
627
+ return callee.name == "input" and id(callee) not in self.refs
628
+ if not (isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier)
629
+ and id(callee.object) not in self.refs):
630
+ return False
631
+ if callee.object.name == "input":
632
+ return callee.member in _REGISTRATION_INPUTS
633
+ if callee.object.name == "ticker" and callee.member in ("inherit", "standard"):
634
+ symbol = ticker_symbol_arg(expr)
635
+ return symbol is not None and self.registration_value(symbol, seen)
636
+ return False
637
+ if isinstance(expr, BinOp):
638
+ return (expr.op in ("+", "==", "!=", "and", "or")
639
+ and self.registration_value(expr.left, seen)
640
+ and self.registration_value(expr.right, seen))
641
+ if isinstance(expr, UnaryOp):
642
+ return expr.op == "not" and self.registration_value(expr.operand, seen)
643
+ if isinstance(expr, Ternary):
644
+ return all(self.registration_value(part, seen)
645
+ for part in (expr.condition, expr.true_val, expr.false_val))
646
+ return False
647
+
648
+
649
+ def _error(node, message: str, filename: str) -> Diagnostic:
650
+ loc = getattr(node, "loc", None) or SourceLocation(file=filename, line=1, col=1, end_col=1)
651
+ return Diagnostic(level=Level.ERROR, phase=Phase.ANALYZER, location=loc, message=message,
652
+ hint=("Pass the symbol and timeframe as literals, inputs or globals "
653
+ "at the helper's call sites."))
654
+
655
+
656
+ def specialize_security_contexts(program: Program, filename: str = "<input>") -> Program:
657
+ """Resolve the context of every helper request (module docstring)."""
658
+ while True:
659
+ try:
660
+ return _specialize(program, filename)
661
+ except _FeedFallback as exc:
662
+ # Nothing is rewritten before the plan holds: these requests keep
663
+ # their deferred refusal, and the pass starts over without them.
664
+ from .external_requests import unpin_requests
665
+ unpin_requests(program, exc.reasons)
666
+
667
+
668
+ def _specialize(program: Program, filename: str) -> Program:
669
+ prog = ScriptIndex(program)
670
+ _mark_unreached(prog, _reached(prog))
671
+ # The requests whose context this pass owns, by helper; those owned for
672
+ # their symbol or timeframe; the payload parameter reads of those owned
673
+ # for their payload.
674
+ owned: dict[str, list[FuncCall]] = {}
675
+ context_owned: set[int] = set()
676
+ payload_reads: dict[int, list[Identifier]] = {}
677
+ # Another symbol's request whose symbol or timeframe reads a name this
678
+ # pass does not resolve (a method's parameter, a block's local) reads no
679
+ # feed: the support checker keeps those, this is its backstop.
680
+ stray = {id(request): "its symbol or timeframe reads a name of a method or a block"
681
+ for owner, requests in prog.requests.items() if owner not in prog.funcs
682
+ for request in requests if _is_feed(request)
683
+ and any(prog.depends_on_scope(arg) for arg in _request_args(request))}
684
+ if stray:
685
+ raise _FeedFallback(stray)
686
+ for owner, requests in prog.requests.items():
687
+ if owner not in prog.funcs:
688
+ continue
689
+ for request in requests:
690
+ symbol, tf = _request_args(request)
691
+ if (prog.depends_on_scope(symbol) or prog.depends_on_scope(tf)) and (
692
+ _is_feed(request) or not (
693
+ _request_name(request) == "security"
694
+ and _analyzer_resolves(prog, owner, symbol, tf))):
695
+ context_owned.add(id(request))
696
+ reads = _payload_params(prog, owner, request)
697
+ if reads and not _analyzer_binds_payload(prog, owner, request, reads):
698
+ payload_reads[id(request)] = reads
699
+ if id(request) in context_owned or id(request) in payload_reads:
700
+ owned.setdefault(owner, []).append(request)
701
+ while True:
702
+ if not owned:
703
+ return program
704
+ try:
705
+ leads, instances, detail, top = _plan(
706
+ prog, owned, context_owned, payload_reads, filename)
707
+ break
708
+ except _Fallback as exc:
709
+ # These requests keep the earlier lowering of their payload (all
710
+ # of them, should none of these be owned for it).
711
+ dropped = exc.requests & payload_reads.keys()
712
+ for request_id in dropped or list(payload_reads):
713
+ payload_reads.pop(request_id)
714
+ owned = {func: kept for func, requests in owned.items()
715
+ if (kept := [r for r in requests if id(r) in context_owned
716
+ or id(r) in payload_reads])}
717
+ _type_context_params(prog, owned, leads)
718
+
719
+ # Every call path keys each request of another symbol this pass owns: the
720
+ # support checker's warning that it reads a feed now holds.
721
+ warnings = []
722
+ for requests in owned.values():
723
+ for request in requests:
724
+ notes = request.annotations or {}
725
+ if _FEED_WARNING_ANNOTATION in notes:
726
+ warnings.append(notes[_FEED_WARNING_ANNOTATION])
727
+ request.annotations = {k: v for k, v in notes.items()
728
+ if k != _FEED_WARNING_ANNOTATION}
729
+ if warnings:
730
+ pass_notes = program.annotations = dict(program.annotations or {})
731
+ pass_notes[PASS_WARNINGS_ANNOTATION] = [
732
+ *pass_notes.get(PASS_WARNINGS_ANNOTATION, ()), *warnings]
733
+
734
+ # A request no top-level statement reaches never runs: one owned for its
735
+ # context keeps the chart timeframe, and no read of a parameter of its
736
+ # helper warns.
737
+ for func, requests in owned.items():
738
+ if func not in instances:
739
+ for request in requests:
740
+ if id(request) in context_owned:
741
+ request.annotations = {**(request.annotations or {}),
742
+ DEAD_ANNOTATION: True}
743
+
744
+ # Name every instance and build its definition: a helper's first
745
+ # signature keeps its own, every other one is a copy of it.
746
+ taken = {n.name for n in _walk(program) if isinstance(n, Identifier)} | set(prog.funcs)
747
+ taken |= {name for n in _walk(program) for name in _declared_names(n)}
748
+ names: dict[tuple, str] = {}
749
+ defs: dict[tuple, tuple[FuncDef, dict | None]] = {}
750
+ new_defs: dict[str, list[FuncDef]] = {}
751
+ for func, sigs in instances.items():
752
+ original = prog.funcs[func]
753
+ for index, signature in enumerate(sigs):
754
+ if index == 0:
755
+ names[signature] = func
756
+ defs[signature] = (original, None)
757
+ continue
758
+ n = index
759
+ while f"{func}__pfctx{n}" in taken:
760
+ n += 1
761
+ names[signature] = f"{func}__pfctx{n}"
762
+ taken.add(names[signature])
763
+ memo: dict = {}
764
+ clone = copy.deepcopy(original, memo)
765
+ clone.name = names[signature]
766
+ _rename_locals(prog, original, memo, names[signature][len(func):], taken)
767
+ defs[signature] = (clone, memo)
768
+ new_defs.setdefault(func, []).append(clone)
769
+
770
+ for signature, (fdef, memo) in defs.items():
771
+ info = detail[signature]
772
+ mapped = (lambda node: node) if memo is None else (lambda node, m=memo: m[id(node)])
773
+ for request, (symbol, tf), payload in info["contexts"]:
774
+ target = mapped(request)
775
+ target.annotations = {
776
+ **(target.annotations or {}),
777
+ CONTEXT_ANNOTATION: {"symbol": _alias_value(prog, symbol), "timeframe": tf}}
778
+ if payload:
779
+ values = dict(payload)
780
+ replace_nodes(target, {
781
+ id(mapped(read)): copy.deepcopy(values[prog.refs[id(read)][2]])
782
+ for read in payload_reads[id(request)]})
783
+ for call, child in info["kids"]:
784
+ target = mapped(call)
785
+ target.callee = replace(target.callee, name=names[child])
786
+ for call, signature in top:
787
+ call.callee = replace(call.callee, name=names[signature])
788
+
789
+ body = []
790
+ for stmt in program.body:
791
+ body.append(stmt)
792
+ if isinstance(stmt, FuncDef):
793
+ body.extend(new_defs.get(stmt.name, ()))
794
+ program.body = body
795
+ return program
796
+
797
+
798
+ def _plan(prog: ScriptIndex, owned: dict, context_owned: set[int],
799
+ payload_reads: dict[int, list], filename: str):
800
+ """The helpers leading to ``owned``, their instances, each instance's
801
+ contexts and child calls, and the top-level calls' instances. Refuses a
802
+ symbol or timeframe no registration can compute; raises ``_Fallback``
803
+ for payloads this pass cannot rewrite."""
804
+ # Every helper whose calls lead to an owned request.
805
+ leads = set(owned)
806
+ changed = True
807
+ while changed:
808
+ changed = False
809
+ for name in prog.funcs:
810
+ if name in leads:
811
+ continue
812
+ if any(prog.owner.get(id(c)) == name for g in leads for c in prog.calls[g]):
813
+ leads.add(name)
814
+ changed = True
815
+
816
+ errors: dict[int, Diagnostic] = {}
817
+
818
+ def refuse(request: FuncCall, message: str) -> None:
819
+ errors.setdefault(id(request), _error(request, message, filename))
820
+
821
+ def payload_only(funcs) -> list[int]:
822
+ return [id(r) for g in funcs for r in owned.get(g, ()) if id(r) not in context_owned]
823
+
824
+ context_first = {func: first for func, requests in owned.items()
825
+ if (first := next((r for r in requests if id(r) in context_owned), None))}
826
+ for func in sorted(leads):
827
+ if func in prog.overloaded:
828
+ problem = "is overloaded, and its overloads' call paths are not told apart"
829
+ else:
830
+ problem = next((f"is called from {owner}, whose calls this pass does not follow"
831
+ for call in prog.calls[func]
832
+ if (owner := prog.owner.get(id(call))) is not None
833
+ and owner not in prog.funcs), None)
834
+ if problem is None:
835
+ continue
836
+ reach = {func} | _reachable(prog, func, leads)
837
+ feeds = {id(r): f"helper '{func}' {problem}"
838
+ for g in reach for r in owned.get(g, ()) if _is_feed(r)}
839
+ if feeds:
840
+ raise _FeedFallback(feeds)
841
+ # The request a helper leads to, for the message.
842
+ target = context_first.get(func) or next(
843
+ (context_first[g] for g in sorted(context_first) if g in reach), None)
844
+ if target is None:
845
+ dropped = payload_only(reach) or list(payload_reads)
846
+ if dropped:
847
+ raise _Fallback(dropped)
848
+ # ``_reachable`` walks an overloaded helper's last definition.
849
+ target = next(r for requests in owned.values() for r in requests)
850
+ refuse(target, f"request.{_request_name(target)} context: helper '{func}' {problem}")
851
+ if errors:
852
+ raise CompileError(list(errors.values()))
853
+
854
+ instances: dict[str, list] = {} # helper -> distinct signatures, first seen first
855
+ detail: dict[tuple, dict] = {} # signature -> contexts and child calls
856
+ budget = [0]
857
+
858
+ def instantiate(func: str, env: dict, path: tuple) -> tuple:
859
+ budget[0] += 1
860
+ if budget[0] > _MAX_INSTANCES or func in {name for name, _ in path}:
861
+ raise _Unresolvable("the helpers reach it through too many call paths")
862
+ here = path + ((func, env),)
863
+ contexts = []
864
+ for request in owned.get(func, []):
865
+ symbol, tf = _request_args(request)
866
+ values = []
867
+ for what, arg in (("symbol", symbol), ("timeframe", tf)):
868
+ chain = " -> ".join(f"{name}()" for name, _ in here)
869
+ try:
870
+ values.append(prog.resolve(arg, env) if arg is not None else None)
871
+ except _Unresolvable as exc:
872
+ if id(request) not in context_owned:
873
+ raise _Fallback([id(request)]) from exc
874
+ if _is_feed(request):
875
+ raise _FeedFallback({id(request): (
876
+ f"its {what} cannot be resolved before the first bar on the "
877
+ f"call path {chain}: {exc}")}) from exc
878
+ refuse(request, f"request.{_request_name(request)} {what} cannot be "
879
+ f"resolved before the first bar on the call path "
880
+ f"{chain}: {exc}")
881
+ values.append(None)
882
+ continue
883
+ if (what == "symbol" and _is_feed(request) and values[-1] is not None
884
+ and not prog.registration_value(values[-1])):
885
+ raise _FeedFallback({id(request): (
886
+ f"its symbol {_spell(values[-1])} on the call path {chain} is not "
887
+ "a value registration computes before the first bar")})
888
+ if (what == "timeframe" and _is_feed(request) and values[-1] is not None
889
+ and not prog.registers_timeframe(values[-1])):
890
+ raise _FeedFallback({id(request): (
891
+ f"its timeframe {_spell(values[-1])} on the call path {chain} is "
892
+ "not a value registration computes before the first bar")})
893
+ payload = ()
894
+ if id(request) in payload_reads:
895
+ try:
896
+ payload = _payload_values(prog, func, request, payload_reads[id(request)],
897
+ env)
898
+ except _Unresolvable as exc:
899
+ raise _Fallback([id(request)]) from exc
900
+ contexts.append((request, tuple(values), payload))
901
+ kids = []
902
+ for call in _body_calls(prog, func, leads):
903
+ callee = call.callee.name
904
+ callee_env = {p: (prog.param_arg(callee, call, p), env)
905
+ for p in prog.funcs[callee].params}
906
+ kids.append((call, instantiate(callee, callee_env, here)))
907
+ signature = (func, tuple(context_key((v, payload)) for _, v, payload in contexts),
908
+ tuple(sig for _, sig in kids))
909
+ if signature not in detail:
910
+ detail[signature] = {"contexts": contexts, "kids": kids}
911
+ instances.setdefault(func, []).append(signature)
912
+ return signature
913
+
914
+ top: list[tuple[FuncCall, tuple]] = []
915
+ try:
916
+ for name in sorted(leads, key=lambda n: prog.program.body.index(prog.funcs[n])):
917
+ for call in prog.calls[name]:
918
+ if prog.owner.get(id(call)) is not None:
919
+ continue
920
+ env = {p: (prog.param_arg(name, call, p), None)
921
+ for p in prog.funcs[name].params}
922
+ top.append((call, instantiate(name, env, ())))
923
+ except _Unresolvable as exc:
924
+ feeds = {id(r): str(exc) for requests in owned.values() for r in requests
925
+ if _is_feed(r)}
926
+ if feeds:
927
+ raise _FeedFallback(feeds) from exc
928
+ if payload_reads:
929
+ raise _Fallback(list(payload_reads)) from exc
930
+ request = next(iter(owned.values()))[0]
931
+ refuse(request, f"request.{_request_name(request)} context: {exc}")
932
+ if errors:
933
+ raise CompileError(list(errors.values()))
934
+ return leads, instances, detail, top
935
+
936
+
937
+ def _declared_names(node) -> list[str]:
938
+ """The names a declaration or a loop binds."""
939
+ if isinstance(node, VarDecl):
940
+ return [node.name] if node.name else []
941
+ if isinstance(node, TupleAssign):
942
+ return list(node.names)
943
+ if isinstance(node, ForStmt):
944
+ return [node.var]
945
+ if isinstance(node, ForInStmt):
946
+ return [node.var] if node.var else list(node.vars or ())
947
+ return []
948
+
949
+
950
+ def _rename_locals(prog: ScriptIndex, original: FuncDef, memo: dict, suffix: str,
951
+ taken: set[str]) -> None:
952
+ """Give a helper copy's locals and loop variables names of their own
953
+ (``v`` becomes ``v__pfctx1``): the analyzer keeps a callable local's
954
+ history in one buffer per name, which a copy's ``v[1]`` would share with
955
+ the original's, and refuses the script."""
956
+ fresh: dict[tuple, str] = {}
957
+
958
+ def rename(binding: tuple, name: str) -> str:
959
+ if name == "_" or binding is None:
960
+ return name
961
+ if binding not in fresh:
962
+ candidate, n = f"{name}{suffix}", 0
963
+ while candidate in taken:
964
+ n += 1
965
+ candidate = f"{name}{suffix}_{n}"
966
+ taken.add(candidate)
967
+ fresh[binding] = candidate
968
+ return fresh[binding]
969
+
970
+ for node in _walk(original.body):
971
+ clone = memo[id(node)]
972
+ if isinstance(node, VarDecl) and node.name:
973
+ clone.name = rename(prog.decl_binding.get((id(node), node.name)), node.name)
974
+ elif isinstance(node, TupleAssign):
975
+ clone.names = [rename(prog.decl_binding.get((id(node), name)), name)
976
+ for name in node.names]
977
+ elif isinstance(node, ForStmt):
978
+ clone.var = rename(("bound", id(node), node.var), node.var)
979
+ elif isinstance(node, ForInStmt):
980
+ if node.var:
981
+ clone.var = rename(("bound", id(node), node.var), node.var)
982
+ else:
983
+ clone.vars = [rename(("bound", id(node), name), name)
984
+ for name in node.vars or ()]
985
+ for node in _walk(original.body):
986
+ if isinstance(node, Identifier) and prog.refs.get(id(node)) in fresh:
987
+ memo[id(node)].name = fresh[prog.refs[id(node)]]
988
+
989
+
990
+ def _payload_params(prog: ScriptIndex, func: str, request: FuncCall) -> list[Identifier]:
991
+ """The reads of ``func``'s parameters in ``request``'s payload (not a
992
+ tuple's: a helper returning one does not compile)."""
993
+ if _request_name(request) != "security" or isinstance(_request_payload(request),
994
+ TupleLiteral):
995
+ return []
996
+ return [node for node in _walk(_request_payload(request))
997
+ if isinstance(node, Identifier) and prog.refs.get(id(node), ())[:2] == ("param", func)]
998
+
999
+
1000
+ def _analyzer_binds_payload(prog: ScriptIndex, func: str, request: FuncCall,
1001
+ reads: list[Identifier]) -> bool:
1002
+ """The analyzer's lowering binds these payload reads exactly: each is a
1003
+ ``ta.*`` length or a history index, every call of ``func`` is at the top
1004
+ level and passes each parameter one value, the same spelling, which holds
1005
+ no input call (each untitled one has its own key). The analyzer's
1006
+ call-site clones for differing symbols or timeframes give every clone the
1007
+ first call's length. A helper nothing calls never runs; an overloaded
1008
+ one's calls are not told apart, and keep the analyzer's lowering."""
1009
+ calls = prog.calls[func]
1010
+ if not calls or func in prog.overloaded:
1011
+ return True
1012
+ if any(prog.owner.get(id(call)) is not None for call in calls):
1013
+ return False
1014
+ objects, lengths, _ = _payload_positions(prog, request)
1015
+ if any(id(read) not in lengths for read in reads):
1016
+ return False
1017
+ for param in {prog.refs[id(read)][2] for read in reads}:
1018
+ args = [prog.param_arg(func, call, param) for call in calls]
1019
+ if len({context_key(arg) for arg in args}) > 1 or any(
1020
+ id(node) in prog.input_names for arg in args for node in _walk(arg)):
1021
+ return False
1022
+ return True
1023
+
1024
+
1025
+ def _payload_positions(prog: ScriptIndex, request: FuncCall) -> tuple[set[int], set[int], set[int]]:
1026
+ """The ids of the payload's history objects (``e`` in ``e[1]``), of its
1027
+ ``ta.*`` length arguments and history indices -- where every earlier
1028
+ build lowered a helper parameter without refusing it -- and of the nodes
1029
+ under a builtin call the builder renders on the chart's terms (``nz``,
1030
+ ``str.*``, ...) with no ``ta.*`` or user call between them."""
1031
+ from .analyzer.tables import TA_PERIOD_ARG
1032
+
1033
+ objects, lengths, rendered = set(), set(), set()
1034
+
1035
+ def visit(value, under: bool) -> None:
1036
+ if isinstance(value, (list, tuple)):
1037
+ for item in value:
1038
+ visit(item, under)
1039
+ return
1040
+ if isinstance(value, dict):
1041
+ for item in value.values():
1042
+ visit(item, under)
1043
+ return
1044
+ if not isinstance(value, ASTNode):
1045
+ return
1046
+ if under:
1047
+ rendered.add(id(value))
1048
+ if isinstance(value, Subscript):
1049
+ objects.add(id(value.object))
1050
+ lengths.add(id(value.index))
1051
+ elif isinstance(value, FuncCall):
1052
+ callee = value.callee
1053
+ space = (callee.object.name if isinstance(callee, MemberAccess)
1054
+ and isinstance(callee.object, Identifier)
1055
+ and id(callee.object) not in prog.refs else None)
1056
+ if space == "ta":
1057
+ index = TA_PERIOD_ARG.get(callee.member)
1058
+ if index is not None and index < len(value.args):
1059
+ lengths.add(id(value.args[index]))
1060
+ if "length" in value.kwargs:
1061
+ lengths.add(id(value.kwargs["length"]))
1062
+ under = False
1063
+ elif isinstance(callee, Identifier) and callee.name in prog.funcs:
1064
+ under = False
1065
+ elif space != "math":
1066
+ under = True
1067
+ for child in _children(value):
1068
+ visit(child, under)
1069
+
1070
+ visit(_request_payload(request), False)
1071
+ return objects, lengths, rendered
1072
+
1073
+
1074
+ # The chart's own series a payload lowers on the requested bars, as a value
1075
+ # and as a history object.
1076
+ _VALUE_SERIES = frozenset({
1077
+ "open", "high", "low", "close", "volume", "hl2", "hlc3", "ohlc4", "hlcc4",
1078
+ })
1079
+ _HISTORY_SERIES = _VALUE_SERIES
1080
+ _VALUE_CALLS = frozenset({"nz", "int", "float"})
1081
+ _CONSTANT_INPUTS = frozenset({"int", "float", "bool", "string"})
1082
+
1083
+
1084
+ class _Lowered:
1085
+ """Whether the request builder lowers a payload parameter's value on the
1086
+ requested bars in the read's place, never on the chart's terms or into
1087
+ C++ that does not compile. It reads a global only when the global is not
1088
+ reassigned or ``var`` and its value is itself lowered. A call's argument
1089
+ names globals declared before the call, which may follow the helper: the
1090
+ copy's read carries ``GLOBAL_ANNOTATION`` (``resolve_payload``), which
1091
+ the builder takes for the binding the analyzer cannot give it there. The
1092
+ analyzer types such a name a float, so a global declared after the
1093
+ helper is put in only when its value is a number or a bool (``_numeric``):
1094
+ a later string input typed the request a double, which did not compile."""
1095
+
1096
+ def __init__(self, prog: ScriptIndex, func: str) -> None:
1097
+ from .analyzer.tables import TA_TUPLE_RETURNS
1098
+
1099
+ self.prog = prog
1100
+ self.tuple_ta = TA_TUPLE_RETURNS
1101
+ # (rule, global name, globals_ok) -> verdict: a global's value is
1102
+ # judged once however many reads share it.
1103
+ self.memo: dict = {}
1104
+ self.declared: dict = {}
1105
+ # The globals declared after the helper.
1106
+ self.later: set[str] = set()
1107
+ helper = prog.program.body.index(prog.funcs[func])
1108
+ for index, stmt in enumerate(prog.program.body):
1109
+ names = ([stmt.name] if isinstance(stmt, VarDecl) and stmt.name
1110
+ else list(stmt.names) if isinstance(stmt, TupleAssign) else [])
1111
+ for name in names:
1112
+ if name in self.declared:
1113
+ continue
1114
+ self.declared[name] = stmt.value if isinstance(stmt, VarDecl) else None
1115
+ if index > helper:
1116
+ self.later.add(name)
1117
+
1118
+ def _global(self, node, seen):
1119
+ """A stable global's declared value, else None."""
1120
+ if (node.name in seen or self.declared.get(node.name) is None
1121
+ or ("global", node.name) in self.prog.unstable):
1122
+ return None
1123
+ if node.name in self.later and not self._numeric(self.declared[node.name], seen):
1124
+ return None
1125
+ return self.declared[node.name]
1126
+
1127
+ def _numeric(self, node, seen: frozenset) -> bool:
1128
+ """``node`` is a number or a bool: literals, bar series, ``ta.*`` and
1129
+ ``math.*`` calls, numeric inputs and ``input.source``, and operators
1130
+ and ternaries over them, through stable globals."""
1131
+ if isinstance(node, (NumberLiteral, BoolLiteral, NaLiteral)):
1132
+ return True
1133
+ if isinstance(node, Identifier):
1134
+ if node.name in self.prog.program_names:
1135
+ value = self.declared.get(node.name)
1136
+ return (node.name not in seen and value is not None
1137
+ and ("global", node.name) not in self.prog.unstable
1138
+ and self._numeric(value, seen | {node.name}))
1139
+ return node.name in _VALUE_SERIES
1140
+ if isinstance(node, FuncCall):
1141
+ kind = self._call(node)
1142
+ if kind == "input":
1143
+ return node.callee.member in ("int", "float", "bool")
1144
+ if kind == "value":
1145
+ return bool(node.args) and all(self._numeric(a, seen) for a in node.args)
1146
+ if (kind is None and isinstance(node.callee, Identifier)
1147
+ and id(node.callee) not in self.prog.refs
1148
+ and self._inlined_function(node.callee.name)):
1149
+ return self._inlined_numeric(node.callee.name)
1150
+ return kind in ("ta", "math", "source", "stop")
1151
+ if isinstance(node, BinOp):
1152
+ return self._numeric(node.left, seen) and self._numeric(node.right, seen)
1153
+ if isinstance(node, UnaryOp):
1154
+ return self._numeric(node.operand, seen)
1155
+ if isinstance(node, Ternary):
1156
+ return self._numeric(node.true_val, seen) and self._numeric(node.false_val, seen)
1157
+ if isinstance(node, Subscript):
1158
+ return self._numeric(node.object, seen)
1159
+ return False
1160
+
1161
+ def _call(self, node) -> str | None:
1162
+ """``ta``, ``math``, ``input``, ``source`` or ``value`` for a call the
1163
+ builder lowers, else None."""
1164
+ callee = node.callee
1165
+ if (isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier)
1166
+ and callee.object.name not in self.declared
1167
+ and id(callee.object) not in self.prog.refs):
1168
+ space, member = callee.object.name, callee.member
1169
+ if space == "ta" and member not in self.tuple_ta:
1170
+ return "ta"
1171
+ if space == "math":
1172
+ return "math"
1173
+ if space == "input" and member in _CONSTANT_INPUTS:
1174
+ return "input"
1175
+ if space == "input" and member == "source":
1176
+ # The series it selects, read on the requested bars.
1177
+ return "source"
1178
+ if space == "request" and member == "security" and self._heikinashi_request(node):
1179
+ # A request of Heikin-Ashi bars inside the payload: evaluating
1180
+ # it stops the run (the codegen's
1181
+ # ``_security_nested_heikinashi_request``), so a selection
1182
+ # that never takes it reads the requested bars.
1183
+ return "stop"
1184
+ return None
1185
+ if (isinstance(callee, Identifier) and callee.name == "input"
1186
+ and callee.name not in self.declared and callee.name not in self.prog.funcs
1187
+ and node.args and isinstance(node.args[0], Identifier)
1188
+ and node.args[0].name in _VALUE_SERIES
1189
+ and id(node.args[0]) not in self.prog.refs):
1190
+ # ``input(close)``, the source overload.
1191
+ return "source"
1192
+ if (isinstance(callee, Identifier) and callee.name in _VALUE_CALLS
1193
+ and callee.name not in self.declared and callee.name not in self.prog.funcs):
1194
+ return "value"
1195
+ return None
1196
+
1197
+ def _judged(self, rule: str, node, seen, globals_ok, judge) -> bool:
1198
+ key = (rule, node.name, globals_ok)
1199
+ if key not in self.memo:
1200
+ declared = self._global(node, seen)
1201
+ self.memo[key] = declared is not None and judge(declared, seen | {node.name})
1202
+ return self.memo[key]
1203
+
1204
+ def _heikinashi_request(self, node) -> bool:
1205
+ """``node`` (a ``request.security``) requests Heikin-Ashi bars: a
1206
+ ``ticker.heikinashi(...)`` symbol, written there or held by a
1207
+ global."""
1208
+ symbol = node.args[0] if node.args else node.kwargs.get("symbol")
1209
+ if isinstance(symbol, Identifier) and self.prog.refs.get(id(symbol), ("",))[0] == "global":
1210
+ symbol = self.declared.get(symbol.name)
1211
+ return (isinstance(symbol, FuncCall) and isinstance(symbol.callee, MemberAccess)
1212
+ and isinstance(symbol.callee.object, Identifier)
1213
+ and symbol.callee.object.name == "ticker"
1214
+ and id(symbol.callee.object) not in self.prog.refs
1215
+ and symbol.callee.member == "heikinashi")
1216
+
1217
+ def _user_call(self, node, seen: frozenset, globals_ok: bool) -> bool:
1218
+ """``node``, a global's value, is a call of a user function the
1219
+ builder inlines whole on the requested bar (``_inlined_function``)
1220
+ on lowered arguments. The codegen lowers such a global once per
1221
+ evaluator, and keeps its history on the requested clock
1222
+ (``_security_global_history_value``)."""
1223
+ return (isinstance(node, FuncCall) and isinstance(node.callee, Identifier)
1224
+ and id(node.callee) not in self.prog.refs
1225
+ and self._inlined_function(node.callee.name)
1226
+ and all(self.value(a, seen, globals_ok)
1227
+ for a in (*node.args, *node.kwargs.values())))
1228
+
1229
+ def _inlined_function(self, name: str, seen: frozenset = frozenset()) -> bool:
1230
+ """``name`` is a user function the builder inlines whole on the
1231
+ requested bar: one definition whose body declares plain locals and
1232
+ ends in an expression, reading its parameters, its locals, bar series
1233
+ and literals through operators, ternaries, single-valued ``ta.*``,
1234
+ ``math.*``, ``int`` / ``float`` / ``nz`` and calls of such functions.
1235
+ No ``var``, reassignment, branch or loop statement, history read,
1236
+ global, request or order."""
1237
+ if name in seen or name in self.prog.overloaded or name not in self.prog.funcs:
1238
+ return False
1239
+ key = ("function", name)
1240
+ if key not in self.memo:
1241
+ self.memo[key] = False # a recursive helper is not one
1242
+ body = self.prog.funcs[name].body
1243
+ ok = bool(body)
1244
+ for index, stmt in enumerate(body):
1245
+ last = index == len(body) - 1
1246
+ if isinstance(stmt, VarDecl) and stmt.name:
1247
+ ok = (not (stmt.is_var or stmt.is_varip)
1248
+ and ("local", id(stmt)) not in self.prog.unstable)
1249
+ expr = stmt.value
1250
+ elif last:
1251
+ expr = stmt.expr if isinstance(stmt, ExprStmt) else stmt
1252
+ else:
1253
+ ok = False
1254
+ if not ok or not self._inlined_expr(name, expr, seen | {name}):
1255
+ ok = False
1256
+ break
1257
+ self.memo[key] = ok
1258
+ return self.memo[key]
1259
+
1260
+ def _inlined_numeric(self, name: str, node=None, seen: frozenset = frozenset()) -> bool:
1261
+ """The result of ``_inlined_function`` ``name`` (or ``node`` in its
1262
+ body) is a number or a bool: numeric literals, bar series, ``ta.*``,
1263
+ ``math.*``, ``int`` / ``float``, comparisons and logic, arithmetic
1264
+ and ternaries over them, its numeric locals and such calls. A
1265
+ parameter or a string is not."""
1266
+ if node is None:
1267
+ if name in seen:
1268
+ return False
1269
+ last = self.prog.funcs[name].body[-1]
1270
+ node = (last.value if isinstance(last, VarDecl)
1271
+ else last.expr if isinstance(last, ExprStmt) else last)
1272
+ seen = seen | {name}
1273
+ if isinstance(node, (NumberLiteral, BoolLiteral, NaLiteral)):
1274
+ return True
1275
+ if isinstance(node, Identifier):
1276
+ ref = self.prog.refs.get(id(node))
1277
+ if ref is None:
1278
+ return node.name in _VALUE_SERIES
1279
+ decl = self.prog.decls.get(ref) if ref[0] == "local" else None
1280
+ return decl is not None and self._inlined_numeric(name, decl.value, seen)
1281
+ if isinstance(node, BinOp):
1282
+ if node.op in ("==", "!=", "<", ">", "<=", ">=", "and", "or"):
1283
+ return True
1284
+ return (self._inlined_numeric(name, node.left, seen)
1285
+ and self._inlined_numeric(name, node.right, seen))
1286
+ if isinstance(node, UnaryOp):
1287
+ return node.op == "not" or self._inlined_numeric(name, node.operand, seen)
1288
+ if isinstance(node, Ternary):
1289
+ return (self._inlined_numeric(name, node.true_val, seen)
1290
+ and self._inlined_numeric(name, node.false_val, seen))
1291
+ if isinstance(node, FuncCall):
1292
+ callee = node.callee
1293
+ if isinstance(callee, MemberAccess):
1294
+ return True # ``_inlined_expr`` admits ``ta.*`` and ``math.*`` only
1295
+ if callee.name in ("int", "float"):
1296
+ return True
1297
+ if callee.name == "nz":
1298
+ return bool(node.args) and self._inlined_numeric(name, node.args[0], seen)
1299
+ return self._inlined_numeric(callee.name, None, seen)
1300
+ return False
1301
+
1302
+ def _inlined_expr(self, func: str, node, seen: frozenset) -> bool:
1303
+ if isinstance(node, _LITERALS):
1304
+ return True
1305
+ if isinstance(node, Identifier):
1306
+ ref = self.prog.refs.get(id(node))
1307
+ if ref is None:
1308
+ return node.name in _VALUE_SERIES
1309
+ if ref[0] == "param":
1310
+ return ref[1] == func
1311
+ return ref[0] == "local" and ref not in self.prog.unstable
1312
+ if isinstance(node, BinOp):
1313
+ return (self._inlined_expr(func, node.left, seen)
1314
+ and self._inlined_expr(func, node.right, seen))
1315
+ if isinstance(node, UnaryOp):
1316
+ return self._inlined_expr(func, node.operand, seen)
1317
+ if isinstance(node, Ternary):
1318
+ return all(self._inlined_expr(func, part, seen)
1319
+ for part in (node.condition, node.true_val, node.false_val))
1320
+ if isinstance(node, FuncCall):
1321
+ args = (*node.args, *node.kwargs.values())
1322
+ callee = node.callee
1323
+ if isinstance(callee, Identifier) and id(callee) not in self.prog.refs:
1324
+ known = (callee.name in _VALUE_CALLS and callee.name not in self.prog.funcs
1325
+ or self._inlined_function(callee.name, seen))
1326
+ elif (isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier)
1327
+ and id(callee.object) not in self.prog.refs):
1328
+ known = (callee.object.name == "math"
1329
+ or callee.object.name == "ta" and callee.member not in self.tuple_ta)
1330
+ else:
1331
+ known = False
1332
+ return known and all(self._inlined_expr(func, a, seen) for a in args)
1333
+ return False
1334
+
1335
+ def value(self, node, seen: frozenset = frozenset(), globals_ok: bool = True) -> bool:
1336
+ if isinstance(node, _LITERALS):
1337
+ return True
1338
+ if isinstance(node, Identifier):
1339
+ if node.name in self.prog.program_names:
1340
+ return globals_ok and self._judged(
1341
+ "value", node, seen, globals_ok,
1342
+ lambda declared, inner: self.value(declared, inner, globals_ok)
1343
+ or self._user_call(declared, inner, globals_ok))
1344
+ return node.name in _VALUE_SERIES
1345
+ if isinstance(node, FuncCall):
1346
+ # An input's arguments are constants (TradingView takes const
1347
+ # ones): its value is the same on every bar. ``nz`` and the casts
1348
+ # render on the chart's terms: no global below them.
1349
+ kind = self._call(node)
1350
+ inner_ok = globals_ok and kind != "value"
1351
+ return kind in ("input", "source", "stop") or (kind is not None and all(
1352
+ self.value(a, seen, inner_ok) for a in (*node.args, *node.kwargs.values())))
1353
+ if isinstance(node, BinOp):
1354
+ return self.value(node.left, seen, globals_ok) and self.value(
1355
+ node.right, seen, globals_ok)
1356
+ if isinstance(node, UnaryOp):
1357
+ return self.value(node.operand, seen, globals_ok)
1358
+ if isinstance(node, Ternary):
1359
+ return all(self.value(part, seen, globals_ok)
1360
+ for part in (node.condition, node.true_val, node.false_val))
1361
+ if isinstance(node, Subscript):
1362
+ return (self.history(node.object, seen, globals_ok)
1363
+ and self.length(node.index, seen))
1364
+ return False
1365
+
1366
+ def history(self, node, seen: frozenset = frozenset(), globals_ok: bool = True) -> bool:
1367
+ """``node`` is a history object: a series with requested-bar history,
1368
+ a ``ta.*`` call (inline, or a global's value), an ``input.source``
1369
+ (the series it selects), an inline operator expression. Not a
1370
+ literal, ``na``, another history read, or a global holding an
1371
+ operator expression."""
1372
+ if isinstance(node, Identifier):
1373
+ if node.name in self.prog.program_names:
1374
+ # A global holding a user call or an operator expression
1375
+ # keeps its history on the requested clock
1376
+ # (``_security_global_history_value``).
1377
+ return globals_ok and self._judged(
1378
+ "history", node, seen, globals_ok,
1379
+ lambda declared, inner: (
1380
+ isinstance(declared, (Identifier, FuncCall))
1381
+ and self.history(declared, inner, globals_ok))
1382
+ or self._user_call(declared, inner, globals_ok)
1383
+ or (isinstance(declared, (BinOp, UnaryOp, Ternary))
1384
+ and self._numeric(declared, inner)
1385
+ and self.value(declared, inner, globals_ok)))
1386
+ return node.name in _HISTORY_SERIES
1387
+ if isinstance(node, FuncCall):
1388
+ kind = self._call(node)
1389
+ return kind == "source" or (kind == "ta" and self.value(node, seen, globals_ok))
1390
+ if isinstance(node, (BinOp, UnaryOp, Ternary)):
1391
+ return self.value(node, seen, globals_ok)
1392
+ return False
1393
+
1394
+ def length(self, node, seen: frozenset = frozenset()) -> bool:
1395
+ """``node`` is the same on every bar: literals, constant inputs,
1396
+ ``math.*`` and operators over them, globals holding them."""
1397
+ if isinstance(node, _LITERALS):
1398
+ return True
1399
+ if isinstance(node, Identifier):
1400
+ return node.name in self.prog.program_names and self._judged(
1401
+ "length", node, seen, True, self.length)
1402
+ if isinstance(node, FuncCall):
1403
+ kind = self._call(node)
1404
+ return kind == "input" or (kind in ("math", "value") and all(
1405
+ self.length(a, seen) for a in (*node.args, *node.kwargs.values())))
1406
+ if isinstance(node, BinOp):
1407
+ return self.length(node.left, seen) and self.length(node.right, seen)
1408
+ if isinstance(node, UnaryOp):
1409
+ return self.length(node.operand, seen)
1410
+ if isinstance(node, Ternary):
1411
+ return all(self.length(part, seen)
1412
+ for part in (node.condition, node.true_val, node.false_val))
1413
+ return False
1414
+
1415
+
1416
+ def _payload_values(prog: ScriptIndex, func: str, request: FuncCall, reads: list[Identifier],
1417
+ env: dict) -> tuple:
1418
+ """Each parameter ``reads`` reads, with its value on this call path, when
1419
+ the builder lowers that value on the requested bars in the place of every
1420
+ read of it (``_Lowered``): a history object, a ``ta.*`` length or history
1421
+ index, a value -- naming no global under a builtin rendered on the
1422
+ chart's terms (``nz(g)`` would read the chart's ``g``)."""
1423
+ objects, lengths, rendered = _payload_positions(prog, request)
1424
+ lowered = _Lowered(prog, func)
1425
+ fdef = prog.funcs[func]
1426
+ # A name the helper declares would capture a global of the same name.
1427
+ shadowing = set(fdef.params)
1428
+ for node in _walk(fdef.body):
1429
+ if isinstance(node, VarDecl):
1430
+ shadowing.add(node.name)
1431
+ elif isinstance(node, TupleAssign):
1432
+ shadowing.update(node.names)
1433
+ elif isinstance(node, ForStmt):
1434
+ shadowing.add(node.var)
1435
+ elif isinstance(node, ForInStmt):
1436
+ shadowing.update([node.var] if node.var else list(node.vars or ()))
1437
+ values = []
1438
+ for param in sorted({prog.refs[id(read)][2] for read in reads}):
1439
+ arg, caller_env = env.get(param, (None, None))
1440
+ if arg is None:
1441
+ raise _Unresolvable(f"parameter '{param}' has no argument")
1442
+ value = prog.resolve_payload(arg, caller_env)
1443
+ for read in (r for r in reads if prog.refs[id(r)][2] == param):
1444
+ if id(read) in objects:
1445
+ ok = lowered.history(value, globals_ok=id(read) not in rendered)
1446
+ elif id(read) in lengths:
1447
+ ok = lowered.length(value)
1448
+ else:
1449
+ ok = lowered.value(value, globals_ok=id(read) not in rendered)
1450
+ if not ok:
1451
+ raise _Unresolvable(f"the value of '{param}' is not lowered on the requested bars")
1452
+ names = {node.name for node in _walk(value) if isinstance(node, Identifier)}
1453
+ if names & shadowing:
1454
+ raise _Unresolvable(f"'{func}' declares '{sorted(names & shadowing)[0]}'")
1455
+ values.append((param, value))
1456
+ return tuple(values)
1457
+
1458
+
1459
+ def _alias_value(prog: ScriptIndex, value):
1460
+ """``value`` with each global alias replaced by its declared value, so a
1461
+ Heikin-Ashi symbol declared after the helper reads as one."""
1462
+ seen: set[str] = set()
1463
+ while (isinstance(value, Identifier) and value.name not in seen
1464
+ and prog.refs.get(id(value), ("global",))[0] == "global"):
1465
+ decl = prog.decls.get(("global", value.name))
1466
+ if decl is None or decl.value is None or ("global", value.name) in prog.unstable:
1467
+ break
1468
+ seen.add(value.name)
1469
+ value = decl.value
1470
+ return value
1471
+
1472
+
1473
+ def _reached(prog: ScriptIndex) -> set[str]:
1474
+ """The helpers a top-level statement, or a method, calls, transitively."""
1475
+ out: set[str] = set()
1476
+ changed = True
1477
+ while changed:
1478
+ changed = False
1479
+ for name, calls in prog.calls.items():
1480
+ if name not in out and any(
1481
+ (owner := prog.owner.get(id(call))) is None or owner in out
1482
+ or owner not in prog.funcs for call in calls):
1483
+ out.add(name)
1484
+ changed = True
1485
+ return out
1486
+
1487
+
1488
+ def _mark_unreached(prog: ScriptIndex, reached: set[str]) -> None:
1489
+ """Annotate each payload read of a parameter of a helper nothing reaches:
1490
+ it never runs, so it does not warn."""
1491
+ for owner, requests in prog.requests.items():
1492
+ if owner in prog.funcs and owner not in reached:
1493
+ for request in requests:
1494
+ for read in _payload_params(prog, owner, request):
1495
+ read.annotations = {**(read.annotations or {}),
1496
+ UNREACHED_ANNOTATION: True}
1497
+
1498
+
1499
+ def _type_context_params(prog: ScriptIndex, owned: dict, leads: set[str]) -> None:
1500
+ """Declare ``string`` every untyped helper parameter that carries a
1501
+ request's symbol or timeframe as it is -- the request's own argument, or
1502
+ passed on whole to such a parameter of another helper. TradingView
1503
+ requires a string there; a helper reached only through another helper's
1504
+ parameter otherwise defaults the parameter to ``double`` while its
1505
+ caller passes a string, which does not compile."""
1506
+ carriers: dict[str, set[str]] = {}
1507
+
1508
+ def bare_param(arg, func: str) -> str | None:
1509
+ binding = prog.refs.get(id(arg)) if isinstance(arg, Identifier) else None
1510
+ if binding is not None and binding[0] == "param" and binding[1] == func:
1511
+ return binding[2]
1512
+ return None
1513
+
1514
+ for func, requests in owned.items():
1515
+ for request in requests:
1516
+ for arg in _request_args(request):
1517
+ param = bare_param(arg, func)
1518
+ if param is not None:
1519
+ carriers.setdefault(func, set()).add(param)
1520
+ changed = True
1521
+ while changed:
1522
+ changed = False
1523
+ for func in leads:
1524
+ for call in _body_calls(prog, func, leads):
1525
+ callee = call.callee.name
1526
+ for param in carriers.get(callee, ()):
1527
+ passed = bare_param(prog.param_arg(callee, call, param), func)
1528
+ if passed is not None and passed not in carriers.get(func, set()):
1529
+ carriers.setdefault(func, set()).add(passed)
1530
+ changed = True
1531
+ for func, params in carriers.items():
1532
+ fdef = prog.funcs[func]
1533
+ notes = fdef.annotations = dict(fdef.annotations or {})
1534
+ hints = list(notes.get("param_type_hints") or [])
1535
+ hints += [None] * (len(fdef.params) - len(hints))
1536
+ for index, param in enumerate(fdef.params):
1537
+ if param in params and not hints[index]:
1538
+ hints[index] = "string"
1539
+ notes["param_type_hints"] = hints
1540
+
1541
+
1542
+ def _reachable(prog: ScriptIndex, func: str, leads: set[str]) -> set[str]:
1543
+ """The helpers ``func``'s body calls, transitively."""
1544
+ seen: set[str] = set()
1545
+ stack = [func]
1546
+ while stack:
1547
+ name = stack.pop()
1548
+ for call in _body_calls(prog, name, leads):
1549
+ callee = call.callee.name
1550
+ if callee not in seen:
1551
+ seen.add(callee)
1552
+ stack.append(callee)
1553
+ return seen
1554
+
1555
+
1556
+ def _body_calls(prog: ScriptIndex, func: str, leads: set[str]) -> list[FuncCall]:
1557
+ """The calls in ``func``'s body to helpers leading to owned requests, in
1558
+ source order."""
1559
+ return [node for node in _walk(prog.funcs[func].body)
1560
+ if isinstance(node, FuncCall) and isinstance(node.callee, Identifier)
1561
+ and node.callee.name in leads and prog.owner.get(id(node)) == func
1562
+ and id(node) in prog.call_ids]
1563
+
1564
+
1565
+ def _analyzer_resolves(prog: ScriptIndex, func: str, symbol, tf) -> bool:
1566
+ """The analyzer's call-site clones serve this request: its symbol and
1567
+ timeframe are each a global expression or a bare parameter of ``func``,
1568
+ and every call of ``func`` passes a global expression for them."""
1569
+ params = []
1570
+ for arg in (symbol, tf):
1571
+ if arg is None or not prog.depends_on_scope(arg):
1572
+ continue
1573
+ binding = prog.refs.get(id(arg)) if isinstance(arg, Identifier) else None
1574
+ if binding is None or binding[0] != "param" or binding[1] != func:
1575
+ return False
1576
+ params.append(binding[2])
1577
+ fdef = prog.funcs[func]
1578
+ for call in prog.calls[func]:
1579
+ for param in params:
1580
+ index = fdef.params.index(param)
1581
+ if param in call.kwargs or index >= len(call.args):
1582
+ return False
1583
+ if prog.depends_on_scope(call.args[index]):
1584
+ return False
1585
+ return True