@pineforge/codegen-pyodide 1.0.0 → 1.1.0

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 (31) hide show
  1. package/glue.py +2 -1
  2. package/package.json +1 -1
  3. package/pineforge_codegen/__init__.py +24 -9
  4. package/pineforge_codegen/analyzer/base.py +190 -1
  5. package/pineforge_codegen/analyzer/call_handlers.py +2 -0
  6. package/pineforge_codegen/analyzer/contracts.py +6 -0
  7. package/pineforge_codegen/analyzer/types.py +16 -5
  8. package/pineforge_codegen/ast_nodes.py +7 -0
  9. package/pineforge_codegen/codegen/base.py +136 -14
  10. package/pineforge_codegen/codegen/checked_settings.py +272 -0
  11. package/pineforge_codegen/codegen/collection_history.py +200 -0
  12. package/pineforge_codegen/codegen/emit_top.py +76 -14
  13. package/pineforge_codegen/codegen/helpers.py +12 -2
  14. package/pineforge_codegen/codegen/host_members.py +8 -0
  15. package/pineforge_codegen/codegen/input.py +36 -3
  16. package/pineforge_codegen/codegen/security.py +56 -1
  17. package/pineforge_codegen/codegen/ta.py +65 -1
  18. package/pineforge_codegen/codegen/types.py +136 -8
  19. package/pineforge_codegen/codegen/visit_call.py +92 -3
  20. package/pineforge_codegen/codegen/visit_expr.py +202 -3
  21. package/pineforge_codegen/codegen/visit_stmt.py +19 -4
  22. package/pineforge_codegen/collection_history.py +1595 -0
  23. package/pineforge_codegen/external_requests.py +3 -0
  24. package/pineforge_codegen/parser.py +19 -4
  25. package/pineforge_codegen/request_discovery.py +506 -0
  26. package/pineforge_codegen/support_checker.py +37 -0
  27. package/pineforge_codegen-1.1.0.tar.gz +0 -0
  28. package/release.json +2 -2
  29. package/tables.json +1 -1
  30. package/transpile.worker.mjs +2 -1
  31. package/pineforge_codegen-1.0.0.tar.gz +0 -0
@@ -858,6 +858,9 @@ def unpin_requests(program: Program, reasons: dict[int, str]) -> None:
858
858
  lowered.annotations = {**(lowered.annotations or {}),
859
859
  UNPINNED_ANNOTATION: marker["message"]}
860
860
  swaps[request_id] = lowered
861
+ # The request leaves the program; its lowering is the deferred
862
+ # refusal's now (``request_discovery`` lists it so).
863
+ request.annotations = {**(request.annotations or {}), LOWERING_ANNOTATION: "unpinned"}
861
864
  warnings.append(pass_warning(
862
865
  request,
863
866
  f"{spell_call(request)}: no data is pinned for this request; the run stops with "
@@ -18,6 +18,7 @@ from .lexer import Token, TokenType
18
18
  from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation
19
19
  from .limits import MAX_NESTING_DEPTH, TimeBudget, limit_error, syntax_children
20
20
  from .ast_nodes import (
21
+ HISTORY_MEMBER_ANNOTATION,
21
22
  ASTNode, ArgOrder,
22
23
  Program, StrategyDecl, ImportStmt,
23
24
  VarDecl, Assignment, TupleAssign,
@@ -1482,6 +1483,11 @@ class Parser:
1482
1483
  def _parse_postfix(self):
1483
1484
  chain_tok = self._current()
1484
1485
  expr = self._parse_primary()
1486
+ # Whether ``expr`` is a history reference this chain wrote, not one in
1487
+ # parentheses: TradingView refuses a member straight after it
1488
+ # (``c[1].v``, CE10011; ``b[1].get_top()``, CE10010), which the
1489
+ # support checker reports (HISTORY_MEMBER_ANNOTATION).
1490
+ after_subscript = False
1485
1491
  while True:
1486
1492
  # Subscript: expr[index]
1487
1493
  if self._check(TokenType.LBRACKET):
@@ -1491,6 +1497,9 @@ class Parser:
1491
1497
  self._consume(TokenType.RBRACKET)
1492
1498
  expr = Subscript(object=expr, index=index)
1493
1499
  self._set_loc(expr, start_tok)
1500
+ after_subscript = True
1501
+ self._check_chain(expr, chain_tok)
1502
+ continue
1494
1503
 
1495
1504
  # Member access: expr.member or expr.member(args)
1496
1505
  elif self._check(TokenType.DOT):
@@ -1501,24 +1510,30 @@ class Parser:
1501
1510
  if self._looks_like_call_template_args():
1502
1511
  template_args = self._parse_template_args()
1503
1512
 
1513
+ annotations = {}
1514
+ if template_args:
1515
+ annotations["template_args"] = template_args
1516
+ if after_subscript:
1517
+ annotations[HISTORY_MEMBER_ANNOTATION] = True
1504
1518
  if self._check(TokenType.LPAREN):
1505
1519
  # Build callee as MemberAccess, then parse call
1506
1520
  callee = MemberAccess(object=expr, member=member_tok.value)
1507
1521
  self._set_loc(callee, member_tok)
1508
- if template_args:
1509
- callee.annotations = {"template_args": template_args}
1522
+ if annotations:
1523
+ callee.annotations = annotations
1510
1524
  expr = self._parse_call_with_callee(callee)
1511
1525
  else:
1512
1526
  expr = MemberAccess(object=expr, member=member_tok.value)
1513
1527
  self._set_loc(expr, member_tok)
1514
- if template_args:
1515
- expr.annotations = {"template_args": template_args}
1528
+ if annotations:
1529
+ expr.annotations = annotations
1516
1530
 
1517
1531
  # Direct call: expr(args) — needed for identifiers followed by (
1518
1532
  elif self._check(TokenType.LPAREN) and self._is_call_position(expr):
1519
1533
  expr = self._parse_call_with_callee(expr)
1520
1534
  else:
1521
1535
  break
1536
+ after_subscript = False
1522
1537
  self._check_chain(expr, chain_tok)
1523
1538
  return expr
1524
1539
 
@@ -0,0 +1,506 @@
1
+ """The other symbols' data a script requests, listed before it runs.
2
+
3
+ ``transpile_full(...)["requests"]`` names every request site that reads
4
+ another symbol's feed, so a host can fetch the bars a run needs before it
5
+ starts it. A run supplies another symbol's bars as one feed per (symbol
6
+ string, timeframe) and the engine looks a site's feed up byte for byte
7
+ (``codegen/emit_top.py``, ``_emit_foreign_security_registration``), so each
8
+ entry states both the way registration computes them:
9
+
10
+ {"line": 7, "fn": "request.security",
11
+ "symbol": {"kind": "input", "title": "Other symbol", "default": "BINANCE:ETHUSDT"},
12
+ "timeframe": {"kind": "chart"},
13
+ "lookahead": false, "gaps": false, "ignore_invalid_symbol": false}
14
+
15
+ The sites are the registrations the C++ makes: ``request.security`` of
16
+ another symbol that the support checker lowered onto its feed
17
+ (``external_requests.FEED_LOWERING``), one entry per context a helper's call
18
+ paths give it (``security_contexts``), with the feed column a footprint reads
19
+ (``column``). A site whose value reaches display and alert sinks only is
20
+ lowered to ``na`` and reads nothing, and a helper nothing reaches never runs:
21
+ neither is listed. A deferred refusal -- another symbol's request whose value
22
+ can reach a trade but which registration cannot key before the first bar --
23
+ is listed with an ``unresolvable`` symbol: its first read stops the run.
24
+ Every other request reads no feed: the chart's symbol, ``request.*`` of
25
+ fundamentals (recorded series, not bars) and ``request.security_lower_tf`` of
26
+ another symbol (it stops the run where it is evaluated).
27
+
28
+ Names are expanded as registration expands them
29
+ (``_substitute_tf_input_reads``: the expression the C++ renders and the run
30
+ evaluates before the first bar), and a ``value`` is computed only over
31
+ ``ScriptIndex.registration_value``'s grammar -- literals, inputs at their
32
+ defaults, ``+ == != and or not``, ternaries and ``ticker.inherit`` /
33
+ ``standard`` -- so discovery states a value where the run computes the same
34
+ one from the same expression.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from .ast_nodes import (
40
+ BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, NaLiteral,
41
+ NumberLiteral, StringLiteral, Subscript, SwitchStmt, Ternary, UnaryOp,
42
+ )
43
+ from .errors import CompileError, Phase
44
+ from .external_requests import FEED_LOWERING, LOWERING_ANNOTATION, _nodes
45
+ from .pine_spelling import is_input_call, pine_string_literal
46
+ from .security_contexts import _REGISTRATION_INPUTS, ScriptIndex, _reached, ticker_symbol_arg
47
+
48
+ # A deferred refusal's lowering (``support_checker._lower_no_data_request``,
49
+ # ``external_requests.unpin_requests``).
50
+ _UNPINNED_LOWERING = "unpinned"
51
+ # The chart's own timeframe where a fold meets ``timeframe.period``.
52
+ _CHART = object()
53
+ _UNKNOWN = object()
54
+ # A folded value longer than any symbol or timeframe is no key.
55
+ _MAX_VALUE_CHARS = 1024
56
+ # Names a fold follows, one through another, and nodes it visits, before it
57
+ # gives up (the value is then unknown).
58
+ _MAX_FOLD_DEPTH = 256
59
+ _MAX_FOLD_STEPS = 100_000
60
+
61
+
62
+ class _Fold:
63
+ """One value's fold: each name expanded and folded once."""
64
+
65
+ def __init__(self, gen) -> None:
66
+ self.gen = gen
67
+ self.names: dict[str, object] = {}
68
+ self.steps = 0
69
+ _PRECEDENCE = {"or": 1, "and": 2, "==": 3, "!=": 3, "<": 4, "<=": 4, ">": 4, ">=": 4,
70
+ "+": 5, "-": 5, "*": 6, "/": 6, "%": 6}
71
+
72
+
73
+ def request_sites(program) -> list[FuncCall]:
74
+ """The ``request.security`` calls the support checker lowered onto
75
+ another symbol's feed or to a deferred refusal, outside the helpers
76
+ nothing reaches: taken before the passes that replace a refusal by its
77
+ ``na`` (``lower_no_data_requests``) or turn a feed back into one
78
+ (``unpin_requests``), which mark it so."""
79
+ sites = [node for node in _nodes(program)
80
+ if isinstance(node, FuncCall) and _call_name(node) == ("request", "security")
81
+ and (node.annotations or {}).get(LOWERING_ANNOTATION)
82
+ in (FEED_LOWERING, _UNPINNED_LOWERING)]
83
+ if not sites:
84
+ return sites
85
+ index = ScriptIndex(program)
86
+ reached = _reached(index)
87
+ return [site for site in sites
88
+ if (owner := index.owner.get(id(site))) not in index.funcs or owner in reached]
89
+
90
+
91
+ def discover_requests(gen, ctx, sites: list[FuncCall]) -> list[dict]:
92
+ """The requests of another symbol's data (module docstring), by line,
93
+ each distinct entry once."""
94
+ entries: list[tuple[tuple, dict]] = []
95
+ calls = {call.sec_id: call for call in ctx.security_calls}
96
+ mutable_reads = set(gen._security_tf_mutable_reads)
97
+
98
+ def add(loc, entry: dict) -> None:
99
+ entry = {"line": loc.line if loc is not None else None, "fn": "request.security",
100
+ **entry}
101
+ where = (loc.line or 0, loc.col or 0) if loc is not None else (0, 0)
102
+ entries.append(((*where, len(entries)), entry))
103
+
104
+ budget = getattr(gen, "_budget", None)
105
+ foreign = [info for info in gen._security_eval_info if info.get("foreign")]
106
+ helpers: set[str] = set()
107
+ unreached: set[str] = set()
108
+ if any(getattr(calls.get(info["sec_id"]), "containing_func", "") for info in foreign):
109
+ # A helper nothing reaches never runs: its registration is never read.
110
+ index = ScriptIndex(ctx.ast)
111
+ helpers = set(index.funcs)
112
+ unreached = helpers - _reached(index)
113
+ try:
114
+ for info in foreign:
115
+ helper = getattr(calls.get(info["sec_id"]), "containing_func", "")
116
+ if helper in helpers and helper in unreached:
117
+ continue
118
+ # Past the time budget the rest are listed as written, unexpanded:
119
+ # discovery never fails a script that transpiled.
120
+ expand = _within(budget)
121
+ entry = {
122
+ "symbol": _guarded(_symbol, gen, info["symbol_node"], expand=expand),
123
+ "timeframe": _guarded(_registered_timeframe, gen, info, expand=expand),
124
+ "lookahead": bool(info.get("lookahead_on")),
125
+ "gaps": bool(info.get("gaps_on")),
126
+ "ignore_invalid_symbol": _guarded(
127
+ _flag, gen, info.get("ignore_invalid_node"), expand=expand),
128
+ }
129
+ column = gen._security_footprint_column(info["sec_id"])
130
+ if column:
131
+ entry["column"] = column
132
+ add(getattr(calls.get(info["sec_id"]), "loc", None), entry)
133
+ for site in sites:
134
+ if (site.annotations or {}).get(LOWERING_ANNOTATION) != _UNPINNED_LOWERING:
135
+ continue
136
+ # The C++ never registered it, so nothing expands its names here:
137
+ # its arguments are listed as written.
138
+ symbol, timeframe = _request_args(site)
139
+ gaps, lookahead = _gaps_lookahead(site)
140
+ ignore = site.kwargs.get("ignore_invalid_symbol")
141
+ add(site.loc, {
142
+ "symbol": {"kind": "unresolvable", "expr": _spell(symbol)},
143
+ "timeframe": _guarded(_timeframe, gen, timeframe, expand=False),
144
+ "lookahead": _barmerge(lookahead, "lookahead_on"),
145
+ "gaps": _barmerge(gaps, "gaps_on"),
146
+ "ignore_invalid_symbol": (
147
+ False if ignore is None else
148
+ ignore.value if isinstance(ignore, BoolLiteral) else None),
149
+ })
150
+ finally:
151
+ # Expanding a name registers a reassigned global's read with the
152
+ # first-bar replay; generation is over, so leave its record as it was.
153
+ gen._security_tf_mutable_reads = mutable_reads
154
+ out: list[dict] = []
155
+ for _, entry in sorted(entries, key=lambda item: item[0]):
156
+ if entry not in out:
157
+ out.append(entry)
158
+ return out
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Symbol and timeframe
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def _within(budget) -> bool:
166
+ """Whether the transpile's time budget still holds."""
167
+ if budget is None:
168
+ return True
169
+ try:
170
+ budget.check(phase=Phase.CODEGEN)
171
+ except CompileError:
172
+ return False
173
+ return True
174
+
175
+
176
+ def _guarded(classify, gen, node, **options):
177
+ """``classify(gen, node)``; a shape it does not know is listed as
178
+ computed, with no value: discovery never fails a script that
179
+ transpiled."""
180
+ try:
181
+ return classify(gen, node, **options)
182
+ except Exception: # noqa: BLE001
183
+ if isinstance(node, dict):
184
+ node = node.get("tf_node")
185
+ return None if classify is _flag else {"kind": "computed", "expr": _spell(node)}
186
+
187
+
188
+ def _symbol(gen, node, expand: bool = True) -> dict:
189
+ """``literal`` / ``input`` / ``computed`` as registration computes the
190
+ symbol string the run keys its feed on."""
191
+ resolved = _passthrough(_expand(gen, node) if expand else node)
192
+ if isinstance(resolved, StringLiteral):
193
+ return {"kind": "literal", "value": resolved.value}
194
+ if _is_registered_input(resolved):
195
+ return _input(gen, resolved)
196
+ if not expand:
197
+ return {"kind": "computed", "expr": _spell(node)}
198
+ return _computed(gen, node, resolved, timeframe=False)
199
+
200
+
201
+ def _registered_timeframe(gen, info: dict, expand: bool = True) -> dict:
202
+ """The timeframe a feed site registers with (``_resolve_security_tf``'s
203
+ ``tf`` / ``tf_expr``, ``configure_security_evaluators``). ``input_tf_``
204
+ is the chart's: a run reading another symbol's feed takes the chart's
205
+ bars unaggregated (the engine refuses it otherwise)."""
206
+ tf, tf_expr = info.get("tf"), info.get("tf_expr")
207
+ if tf:
208
+ return {"kind": "literal", "value": canonical_timeframe(tf)}
209
+ if tf == "" or tf_expr in ("input_tf_", "script_tf_"):
210
+ return {"kind": "chart"}
211
+ return _timeframe(gen, info.get("tf_node"), expand=expand)
212
+
213
+
214
+ def _timeframe(gen, node, expand: bool = True) -> dict:
215
+ """A timeframe registration expands (``expand``), or as written."""
216
+ if node is None:
217
+ return {"kind": "chart"}
218
+ resolved = _expand(gen, node) if expand else node
219
+ if isinstance(resolved, StringLiteral):
220
+ if resolved.value == "":
221
+ return {"kind": "chart"}
222
+ return {"kind": "literal", "value": canonical_timeframe(resolved.value)}
223
+ if _is_timeframe_period(resolved):
224
+ return {"kind": "chart"}
225
+ if _is_registered_input(resolved, timeframe=True):
226
+ return _input(gen, resolved)
227
+ if not expand:
228
+ return {"kind": "computed", "expr": _spell(node)}
229
+ return _computed(gen, node, resolved, timeframe=True)
230
+
231
+
232
+ def canonical_timeframe(tf: str) -> str:
233
+ """The engine's spelling of a feed's timeframe
234
+ (``canonical_symbol_timeframe``): Pine's bare ``D`` / ``W`` / ``M`` /
235
+ ``S`` folded to ``1D`` / ``1W`` / ``1M`` / ``1S``, any other text kept
236
+ as it is (whole minutes ``"240"``, ``<n>D|W|M|S``; a feed matches no
237
+ other spelling)."""
238
+ return "1" + tf if tf in ("D", "W", "M", "S") else tf
239
+
240
+
241
+ def _input(gen, call: FuncCall) -> dict:
242
+ default = _input_default(gen, call)
243
+ return {"kind": "input", "title": gen._get_input_title(call),
244
+ "default": None if default is _UNKNOWN else default}
245
+
246
+
247
+ def _computed(gen, node, resolved, *, timeframe: bool) -> dict:
248
+ out = {"kind": "computed", "expr": _spell(node)}
249
+ value = _fold(_Fold(gen), resolved)
250
+ if value is _CHART and timeframe:
251
+ out["value"] = ""
252
+ elif isinstance(value, str):
253
+ out["value"] = canonical_timeframe(value) if timeframe and value else value
254
+ titles: list[str] = []
255
+ for call in _input_calls(gen, resolved):
256
+ title = gen._get_input_title(call)
257
+ if title not in titles:
258
+ titles.append(title)
259
+ if titles:
260
+ out["inputs"] = titles
261
+ return out
262
+
263
+
264
+ def _flag(gen, node, expand: bool = True) -> bool | None:
265
+ """``ignore_invalid_symbol`` as registration reads it: false when left
266
+ out, else its value at the inputs' defaults (None when unknown)."""
267
+ if node is None:
268
+ return False
269
+ if not expand:
270
+ return node.value if isinstance(node, BoolLiteral) else None
271
+ value = _fold(_Fold(gen), _expand(gen, node))
272
+ return value if isinstance(value, bool) else None
273
+
274
+
275
+ def _expand(gen, node):
276
+ """``node`` with its names expanded as registration expands them."""
277
+ return gen._substitute_tf_input_reads(node, set())
278
+
279
+
280
+ def _passthrough(node):
281
+ """``ticker.inherit`` / ``ticker.standard`` render their symbol argument
282
+ unchanged (``visit_call``)."""
283
+ while (isinstance(node, FuncCall) and _call_name(node) in (
284
+ ("ticker", "inherit"), ("ticker", "standard"))):
285
+ symbol = ticker_symbol_arg(node)
286
+ if symbol is None:
287
+ break
288
+ node = symbol
289
+ return node
290
+
291
+
292
+ def _is_registered_input(node, timeframe: bool = False) -> bool:
293
+ """An input whose value registration reads: ``input.symbol`` /
294
+ ``string`` (``timeframe`` for a timeframe), or ``input()`` of a string."""
295
+ if not is_input_call(node):
296
+ return False
297
+ ns, name = _call_name(node)
298
+ if ns is None:
299
+ default = node.args[0] if node.args else node.kwargs.get("defval")
300
+ return isinstance(default, StringLiteral)
301
+ return name in (_REGISTRATION_INPUTS - {"bool"}) or (timeframe and name == "timeframe")
302
+
303
+
304
+ def _input_default(gen, call: FuncCall):
305
+ default = gen._get_input_default(call)
306
+ if isinstance(default, (StringLiteral, BoolLiteral, NumberLiteral)):
307
+ return default.value
308
+ return _UNKNOWN
309
+
310
+
311
+ def _input_calls(gen, node) -> list[FuncCall]:
312
+ """The input calls ``node`` reads, through the names registration
313
+ expands (a ``switch``'s too), in source order."""
314
+ found: dict[int, FuncCall] = {}
315
+ seen: set[str] = set()
316
+ pending = [node]
317
+ while pending:
318
+ for item in _nodes(pending.pop()):
319
+ if is_input_call(item):
320
+ found.setdefault(id(item), item)
321
+ elif isinstance(item, Identifier) and item.name not in seen:
322
+ seen.add(item.name)
323
+ expanded = _expand(gen, item)
324
+ if expanded is not item:
325
+ pending.append(expanded)
326
+ return sorted(found.values(), key=lambda call: (call.loc.line, call.loc.col)
327
+ if call.loc is not None else (0, 0))
328
+
329
+
330
+ def _fold(state: _Fold, node, depth: int = 0):
331
+ """The value at the inputs' defaults of an expression in
332
+ ``ScriptIndex.registration_value``'s grammar (and, for a timeframe, a
333
+ ``switch`` of single-expression arms, as registration renders it), else
334
+ ``_UNKNOWN``. The chart's own symbol strings are the chart's (unknown
335
+ here); ``timeframe.period`` is ``_CHART``."""
336
+ state.steps += 1
337
+ if depth > _MAX_FOLD_DEPTH or state.steps > _MAX_FOLD_STEPS:
338
+ return _UNKNOWN
339
+ depth += 1
340
+ if isinstance(node, (StringLiteral, BoolLiteral)):
341
+ return node.value
342
+ if isinstance(node, NumberLiteral):
343
+ return node.value
344
+ if _is_timeframe_period(node):
345
+ return _CHART
346
+ if isinstance(node, Identifier):
347
+ # A name registration expands (a switch's subject: the expansion
348
+ # leaves a switch as written); one it keeps has no value here.
349
+ if node.name not in state.names:
350
+ state.names[node.name] = _UNKNOWN # a name read inside its own value
351
+ expanded = _expand(state.gen, node)
352
+ state.names[node.name] = (_UNKNOWN if expanded is node
353
+ else _fold(state, expanded, depth))
354
+ return state.names[node.name]
355
+ if isinstance(node, SwitchStmt):
356
+ return _fold_switch(state, node, depth)
357
+ if isinstance(node, FuncCall):
358
+ if is_input_call(node):
359
+ ns, name = _call_name(node)
360
+ if ns is None or name in _REGISTRATION_INPUTS or name == "timeframe":
361
+ return _input_default(state.gen, node)
362
+ return _UNKNOWN
363
+ passed = _passthrough(node)
364
+ return _UNKNOWN if passed is node else _fold(state, passed, depth)
365
+ if isinstance(node, Ternary):
366
+ condition = _fold(state, node.condition, depth)
367
+ if not isinstance(condition, bool):
368
+ return _UNKNOWN
369
+ return _fold(state, node.true_val if condition else node.false_val, depth)
370
+ if isinstance(node, UnaryOp):
371
+ operand = _fold(state, node.operand, depth)
372
+ return (not operand) if node.op == "not" and isinstance(operand, bool) else _UNKNOWN
373
+ if isinstance(node, BinOp) and node.op in ("+", "==", "!=", "and", "or"):
374
+ left = _fold(state, node.left, depth)
375
+ if left is _UNKNOWN or left is _CHART:
376
+ return _UNKNOWN
377
+ right = _fold(state, node.right, depth)
378
+ if right is _UNKNOWN or right is _CHART:
379
+ return _UNKNOWN
380
+ if node.op == "+":
381
+ if not (isinstance(left, str) and isinstance(right, str)
382
+ and len(left) + len(right) <= _MAX_VALUE_CHARS):
383
+ return _UNKNOWN
384
+ return left + right
385
+ if node.op in ("and", "or"):
386
+ if not (isinstance(left, bool) and isinstance(right, bool)):
387
+ return _UNKNOWN
388
+ return (left and right) if node.op == "and" else (left or right)
389
+ if type(left) is not type(right):
390
+ return _UNKNOWN
391
+ return (left == right) if node.op == "==" else (left != right)
392
+ return _UNKNOWN
393
+
394
+
395
+ def _fold_switch(state: _Fold, node: SwitchStmt, depth: int):
396
+ """A timeframe ``switch`` as registration renders it
397
+ (``_security_tf_switch_runtime_expr``): the first arm whose value equals
398
+ the subject (or whose condition holds), else the default arm, else the
399
+ chart's timeframe; every arm one expression."""
400
+ subject = None
401
+ if node.expr is not None:
402
+ subject = _fold(state, node.expr, depth)
403
+ if subject is _UNKNOWN or subject is _CHART:
404
+ return _UNKNOWN
405
+
406
+ def arm(body):
407
+ if len(body) != 1 or not isinstance(body[0], ExprStmt) or isinstance(
408
+ body[0].expr, NaLiteral):
409
+ return _UNKNOWN
410
+ return _fold(state, body[0].expr, depth)
411
+
412
+ default = node.default_body or None
413
+ for condition, body in node.cases:
414
+ if condition is None:
415
+ default = default or body
416
+ continue
417
+ value = _fold(state, condition, depth)
418
+ if value is _UNKNOWN or value is _CHART:
419
+ return _UNKNOWN
420
+ if (value is True if node.expr is None
421
+ else type(value) is type(subject) and value == subject):
422
+ return arm(body)
423
+ return arm(default) if default else _CHART
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Call shapes and spelling
428
+ # ---------------------------------------------------------------------------
429
+
430
+ def _call_name(node: FuncCall) -> tuple[str | None, str | None]:
431
+ callee = node.callee
432
+ if isinstance(callee, Identifier):
433
+ return None, callee.name
434
+ if isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier):
435
+ return callee.object.name, callee.member
436
+ return None, None
437
+
438
+
439
+ def _is_timeframe_period(node) -> bool:
440
+ """``timeframe.period`` or ``timeframe.main_period``: the chart's."""
441
+ return (isinstance(node, MemberAccess) and node.member in ("period", "main_period")
442
+ and isinstance(node.object, Identifier) and node.object.name == "timeframe")
443
+
444
+
445
+ def _request_args(node: FuncCall):
446
+ symbol = node.args[0] if node.args else node.kwargs.get("symbol")
447
+ timeframe = node.args[1] if len(node.args) > 1 else node.kwargs.get("timeframe")
448
+ return symbol, timeframe
449
+
450
+
451
+ def _gaps_lookahead(node: FuncCall):
452
+ gaps = node.kwargs.get("gaps")
453
+ if gaps is None and len(node.args) > 3:
454
+ gaps = node.args[3]
455
+ lookahead = node.kwargs.get("lookahead")
456
+ if lookahead is None and len(node.args) > 4:
457
+ lookahead = node.args[4]
458
+ return gaps, lookahead
459
+
460
+
461
+ def _barmerge(node, on: str) -> bool | None:
462
+ """A ``barmerge.*`` constant's flag; left out is off; None when computed."""
463
+ if node is None:
464
+ return False
465
+ if (isinstance(node, MemberAccess) and isinstance(node.object, Identifier)
466
+ and node.object.name == "barmerge"):
467
+ return node.member == on
468
+ return None
469
+
470
+
471
+ def _spell(node, parent: int = 0, right: bool = False) -> str:
472
+ """``node`` spelled as Pine source (display only)."""
473
+ if node is None:
474
+ return ""
475
+ if isinstance(node, StringLiteral):
476
+ return pine_string_literal(node.value)
477
+ if isinstance(node, BoolLiteral):
478
+ return "true" if node.value else "false"
479
+ if isinstance(node, NumberLiteral):
480
+ return str(node.value)
481
+ if isinstance(node, NaLiteral):
482
+ return "na"
483
+ if isinstance(node, Identifier):
484
+ return node.name
485
+ if isinstance(node, MemberAccess):
486
+ return f"{_spell(node.object, 9)}.{node.member}"
487
+ if isinstance(node, FuncCall):
488
+ args = [_spell(a) for a in node.args]
489
+ args += [f"{key} = {_spell(value)}" for key, value in node.kwargs.items()]
490
+ return f"{_spell(node.callee, 9)}({', '.join(args)})"
491
+ if isinstance(node, Subscript):
492
+ return f"{_spell(node.object, 9)}[{_spell(node.index)}]"
493
+ if isinstance(node, UnaryOp):
494
+ text = f"not {_spell(node.operand, 7)}" if node.op == "not" else (
495
+ f"{node.op}{_spell(node.operand, 7)}")
496
+ return f"({text})" if parent > 7 else text
497
+ if isinstance(node, BinOp):
498
+ level = _PRECEDENCE.get(node.op, 5)
499
+ text = (f"{_spell(node.left, level)} {node.op} "
500
+ f"{_spell(node.right, level, right=True)}")
501
+ return f"({text})" if level < parent or (right and level == parent) else text
502
+ if isinstance(node, Ternary):
503
+ text = (f"{_spell(node.condition, 1)} ? {_spell(node.true_val)} : "
504
+ f"{_spell(node.false_val)}")
505
+ return f"({text})" if parent else text
506
+ return "..."
@@ -36,6 +36,7 @@ import re
36
36
  from typing import Callable
37
37
 
38
38
  from .ast_nodes import (
39
+ HISTORY_MEMBER_ANNOTATION,
39
40
  ASTNode,
40
41
  Program, StrategyDecl, ImportStmt, TypeField,
41
42
  VarDecl, Assignment, TupleAssign,
@@ -1482,6 +1483,40 @@ class SupportChecker:
1482
1483
  )
1483
1484
  self._visit_children(node)
1484
1485
 
1486
+ def _refuse_history_member(self, node: MemberAccess) -> None:
1487
+ """A field read or a method call written straight after the
1488
+ history-referencing operator: TradingView refuses ``c[1].v``
1489
+ ("User variable identifiers should not contain '.'", CE10011) and
1490
+ ``b[1].get_top()`` (CE10010), and takes the history reference in
1491
+ parentheses, ``(c[1]).v``, which reads the field of the object the
1492
+ variable held (fixtures/udt_history_tv)."""
1493
+ history = node.object
1494
+ receiver = "x[k]"
1495
+ if (isinstance(history, Subscript)
1496
+ and isinstance(history.object, Identifier)
1497
+ and isinstance(history.index, (NumberLiteral, Identifier))):
1498
+ index = (history.index.name if isinstance(history.index, Identifier)
1499
+ else history.index.value)
1500
+ receiver = f"{history.object.name}[{index}]"
1501
+ if id(node) in self._callee_node_ids:
1502
+ self._err(
1503
+ node,
1504
+ f"{receiver}.{node.member}(): TradingView refuses a method "
1505
+ "call straight after the history-referencing operator "
1506
+ "(CE10010; CE10011 after an array's or a matrix's history).",
1507
+ hint=f"Call the method on the history reference in "
1508
+ f"parentheses: ({receiver}).{node.member}().",
1509
+ )
1510
+ return
1511
+ self._err(
1512
+ node,
1513
+ f"{receiver}.{node.member}: TradingView refuses a field read "
1514
+ "straight after the history-referencing operator (CE10011: "
1515
+ "\"User variable identifiers should not contain '.'\").",
1516
+ hint=f"Read the field of the history reference in parentheses: "
1517
+ f"({receiver}).{node.member}.",
1518
+ )
1519
+
1485
1520
  def _visit_FuncCall(self, node: FuncCall) -> None:
1486
1521
  ns, name = _qualified_name(node.callee)
1487
1522
 
@@ -1976,6 +2011,8 @@ class SupportChecker:
1976
2011
  self._visit(node.false_val)
1977
2012
 
1978
2013
  def _visit_MemberAccess(self, node: MemberAccess) -> None:
2014
+ if (node.annotations or {}).get(HISTORY_MEMBER_ANNOTATION):
2015
+ self._refuse_history_member(node)
1979
2016
  chain = _resolve_member_chain(node)
1980
2017
  if (
1981
2018
  chain is not None
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "1.0.0",
2
+ "codegen": "1.1.0",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "968244f5f86ae38984749e3441b602623512a54401ed56affc6f6d75b64c63c9"
6
+ "sha256": "37b1ca85a2006f6f07d52074a7180187536047bfbb857681978a372b1d7f9bef"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "1.0.0",
2
+ "CODEGEN_VERSION": "1.1.0",
3
3
  "TA_CLASS_MAP_KEYS": [
4
4
  "sma",
5
5
  "ema",
@@ -49,7 +49,8 @@ def transpile_json(source: str) -> str:
49
49
  # A script that transpiled carries its warnings in the same entry format.
50
50
  return json.dumps({"ok": True, "cpp": full["cpp"], "inputs": full["inputs"],
51
51
  "strategyParams": full["strategyParams"],
52
- "diagnostics": _diagnostic_entries(full["diagnostics"])})
52
+ "diagnostics": _diagnostic_entries(full["diagnostics"]),
53
+ "requests": full["requests"]})
53
54
  `;
54
55
 
55
56
  const post = (m) => self.postMessage(m);
Binary file