@pineforge/codegen-pyodide 0.10.4 → 1.0.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 (50) hide show
  1. package/README.md +16 -16
  2. package/glue.py +24 -16
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +125 -34
  5. package/pineforge_codegen/analyzer/__init__.py +2 -0
  6. package/pineforge_codegen/analyzer/base.py +754 -76
  7. package/pineforge_codegen/analyzer/call_handlers.py +260 -40
  8. package/pineforge_codegen/analyzer/contracts.py +37 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +30 -4
  10. package/pineforge_codegen/analyzer/tables.py +49 -8
  11. package/pineforge_codegen/analyzer/types.py +33 -1
  12. package/pineforge_codegen/ast_nodes.py +32 -1
  13. package/pineforge_codegen/block_locals.py +185 -0
  14. package/pineforge_codegen/builtin_keywords.py +42 -0
  15. package/pineforge_codegen/codegen/base.py +896 -156
  16. package/pineforge_codegen/codegen/constant_fold.py +131 -0
  17. package/pineforge_codegen/codegen/drawing.py +221 -79
  18. package/pineforge_codegen/codegen/emit_top.py +946 -213
  19. package/pineforge_codegen/codegen/helpers.py +435 -14
  20. package/pineforge_codegen/codegen/host_members.py +162 -0
  21. package/pineforge_codegen/codegen/input.py +252 -85
  22. package/pineforge_codegen/codegen/security.py +4372 -377
  23. package/pineforge_codegen/codegen/session_market.py +71 -0
  24. package/pineforge_codegen/codegen/ta.py +1188 -100
  25. package/pineforge_codegen/codegen/tables.py +193 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +1882 -78
  28. package/pineforge_codegen/codegen/visit_call.py +920 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +738 -57
  30. package/pineforge_codegen/codegen/visit_stmt.py +595 -49
  31. package/pineforge_codegen/external_requests.py +877 -0
  32. package/pineforge_codegen/lexer.py +104 -22
  33. package/pineforge_codegen/library_inline.py +1304 -0
  34. package/pineforge_codegen/library_modules.py +126 -0
  35. package/pineforge_codegen/library_v5.py +683 -0
  36. package/pineforge_codegen/limits.py +138 -0
  37. package/pineforge_codegen/method_binding.py +33 -0
  38. package/pineforge_codegen/parser.py +384 -68
  39. package/pineforge_codegen/pine_libraries.py +266 -0
  40. package/pineforge_codegen/pine_spelling.py +216 -0
  41. package/pineforge_codegen/pragmas.py +64 -10
  42. package/pineforge_codegen/security_contexts.py +1585 -0
  43. package/pineforge_codegen/session_reads.py +84 -0
  44. package/pineforge_codegen/signatures.py +48 -23
  45. package/pineforge_codegen/support_checker.py +1106 -85
  46. package/pineforge_codegen-1.0.0.tar.gz +0 -0
  47. package/release.json +2 -2
  48. package/tables.json +23 -21
  49. package/transpile.worker.mjs +24 -16
  50. package/pineforge_codegen-0.10.4.tar.gz +0 -0
@@ -8,7 +8,11 @@ own analyzer/codegen/signatures modules so the checker cannot drift.
8
8
  Buckets:
9
9
 
10
10
  * HARD_REJECT_FUNC / HARD_REJECT_NAMESPACE - calls that have no PineForge
11
- semantics at all (e.g. ``request.financial``, ``ticker.*``).
11
+ semantics at all (e.g. ``request.seed``, ``ticker.new``).
12
+ * NO_DATA_REQUEST_FUNC - requests PineForge has no data for
13
+ (``request.financial``, ...): refused only when their value can reach a
14
+ trade; one that reaches display and alert sinks only is lowered to na
15
+ (``external_requests``). ``request.security`` on another symbol too.
12
16
  * DIVERGENT_VARS - built-in variables whose PineForge value diverges from
13
17
  TradingView. They are reported as WARNING (e.g. ``bar_index`` and
14
18
  ``last_bar_index`` depend on the fed data window, ``timenow`` is not
@@ -18,7 +22,8 @@ Buckets:
18
22
  * NOT_YET - calls the runtime could support but the transpiler does not yet
19
23
  emit (e.g. ``max_bars_back``, bare ``barssince``).
20
24
  * request.security - only ``symbol`` / ``timeframe`` / ``expression`` allowed,
21
- symbol must be the current chart symbol.
25
+ symbol must be the current chart symbol (a helper parameter's through its
26
+ call sites), or the value must reach display and alert sinks only.
22
27
  * Declarations - only ``strategy(...)`` accepted; ``indicator(...)`` and
23
28
  ``library(...)`` rejected.
24
29
  * Unknown ``ta.X`` / ``math.X`` / ``str.X`` / ``input.X`` calls (codegen would
@@ -27,11 +32,12 @@ Buckets:
27
32
 
28
33
  from __future__ import annotations
29
34
 
35
+ import re
30
36
  from typing import Callable
31
37
 
32
38
  from .ast_nodes import (
33
39
  ASTNode,
34
- Program, StrategyDecl,
40
+ Program, StrategyDecl, ImportStmt, TypeField,
35
41
  VarDecl, Assignment, TupleAssign,
36
42
  IfStmt, ForStmt, ForInStmt, WhileStmt, SwitchStmt,
37
43
  FuncDef, ExprStmt,
@@ -42,6 +48,16 @@ from .ast_nodes import (
42
48
  TypeDecl, EnumDecl, MethodDef,
43
49
  )
44
50
  from .errors import SourceLocation, Diagnostic, CompileError, Level, Phase
51
+ from .builtin_keywords import POSITIONAL_BUILTINS
52
+ from .pine_spelling import expr_start
53
+ from .external_requests import (
54
+ ABSENT_LOWERING, CHART_FALLBACK_ANNOTATION, FEED_LOWERING, FEED_WARNING_ANNOTATION,
55
+ FOOTPRINT_COLUMN_ANNOTATION, LOWERING_ANNOTATION, NO_DATA_REQUEST_FUNCS,
56
+ RECORDED_LOWERING, FootprintValues, TradeSlice, footprint_column, recorded_key,
57
+ spell_call,
58
+ )
59
+ from .external_requests import _nodes as _walk_nodes
60
+ from .security_contexts import ticker_symbol_arg
45
61
  from . import signatures as sigs
46
62
  from .tv_input_choices import INPUT_SOURCE_SERIES_IDS
47
63
  from .analyzer import TA_CLASS_MAP
@@ -68,7 +84,9 @@ TA_PROPERTY_VARIABLES: frozenset[str] = frozenset(
68
84
  {"obv", "accdist", "nvi", "pvi", "pvt", "wad", "wvad", "iii"}
69
85
  )
70
86
  SUPPORTED_TA: frozenset[str] = frozenset(
71
- set(TA_CLASS_MAP) - {"sum", "vwap_bands"} - set(TA_PROPERTY_VARIABLES) | {"tr", "pivot_point_levels"}
87
+ set(TA_CLASS_MAP) - {
88
+ "sum", "vwap_bands", "vwap_anchored", "vwap_anchored_bands"
89
+ } - set(TA_PROPERTY_VARIABLES) | {"tr", "pivot_point_levels"}
72
90
  )
73
91
  SUPPORTED_MATH: frozenset[str] = frozenset(
74
92
  set(MATH_FUNC_MAP) | set(sigs.MATH_CONSTANTS) | set(sigs.MATH_FUNCTIONS)
@@ -113,8 +131,9 @@ _VISUAL_CONTAINER_TYPES: frozenset[str] = _DRAWING_TYPE_NAMES | frozenset({"tabl
113
131
  SUPPORTED_COLOR_CONST: frozenset[str] = frozenset(COLOR_CONST_MAP)
114
132
  SUPPORTED_COLOR_FUNC: frozenset[str] = frozenset({"new", "rgb", "r", "g", "b", "t"})
115
133
  # Cosmetic color builders with no backtest-logic effect. Warned (not rejected);
116
- # codegen emits a benign default color (0 = na color). color.from_gradient is a
117
- # charting/plot helper that only tints visual output.
134
+ # codegen evaluates the arguments (their side effects and history are
135
+ # TradingView's) and emits a benign default color (0 = na color).
136
+ # color.from_gradient is a charting/plot helper that only tints visual output.
118
137
  COSMETIC_COLOR_FUNC: frozenset[str] = frozenset({"from_gradient"})
119
138
  SUPPORTED_TIMEFRAME_FUNC: frozenset[str] = frozenset({"change", "in_seconds"})
120
139
  SUPPORTED_RUNTIME_FUNC: frozenset[str] = frozenset({"error"})
@@ -124,11 +143,21 @@ SUPPORTED_RUNTIME_FUNC: frozenset[str] = frozenset({"error"})
124
143
  # string statement that hides the typo from the strategy author.
125
144
  SUPPORTED_LOG: frozenset[str] = frozenset({"info", "warning", "error"})
126
145
 
127
- HARD_REJECT_FUNC: dict[str, str] = {
146
+ # Requests PineForge has no data for (``external_requests``): refused only
147
+ # when their value can reach a trade; one that reaches display and alert sinks
148
+ # only is lowered to na with a warning. ``request.security`` on another
149
+ # symbol follows the same rule when it reads no feed; ``request.footprint``
150
+ # as its whole expression reads the feed's delta column.
151
+ NO_DATA_REQUEST_FUNC: dict[str, str] = {
128
152
  "request.financial": "External fundamentals data not available in PineForge.",
129
153
  "request.dividends": "External corporate-action data not available in PineForge.",
130
154
  "request.earnings": "External corporate-action data not available in PineForge.",
131
155
  "request.splits": "External corporate-action data not available in PineForge.",
156
+ "request.footprint": "Footprint (bid/ask volume) data not available in PineForge.",
157
+ }
158
+ assert set(NO_DATA_REQUEST_FUNC) == {f"request.{n}" for n in NO_DATA_REQUEST_FUNCS}
159
+
160
+ HARD_REJECT_FUNC: dict[str, str] = {
132
161
  "request.seed": "External seed data feeds not available in PineForge.",
133
162
  "request.quandl": "External Quandl data not available in PineForge.",
134
163
  "request.currency_rate": "Currency conversion data not available in PineForge.",
@@ -335,16 +364,29 @@ UNSUPPORTED_CONST_NAMESPACES: dict[str, str] = {
335
364
  "xloc": _CONST_NS_VISUAL_MSG,
336
365
  "yloc": _CONST_NS_VISUAL_MSG,
337
366
  "barmerge": (
338
- "is only valid as the gaps/lookahead argument of "
339
- "request.security(...); it has no runtime value as a free "
340
- "expression in PineForge."
367
+ "is not a barmerge constant; only barmerge.gaps_on, "
368
+ "barmerge.gaps_off, barmerge.lookahead_on and barmerge.lookahead_off "
369
+ "exist."
341
370
  ),
342
371
  "alert": (
343
- "is only valid as the freq argument of alert(...); it has no "
344
- "runtime value as a free expression in PineForge."
372
+ "is not a Pine alert constant; only alert.freq_all, "
373
+ "alert.freq_once_per_bar and alert.freq_once_per_bar_close exist."
345
374
  ),
346
375
  }
347
376
 
377
+ # ``barmerge.*`` are constants with a value: a script may hold one
378
+ # (TradingView/Request/3: ``var gapStrategy = gaps ? barmerge.gaps_on :
379
+ # barmerge.gaps_off``) and compare it. As a request's gaps or lookahead only
380
+ # the constant itself is read (``_check_request_security``).
381
+ BARMERGE_MEMBERS = frozenset({"gaps_on", "gaps_off", "lookahead_on", "lookahead_off"})
382
+
383
+ # ``alert.freq_*`` are const strings with a runtime value ("all",
384
+ # "once_per_bar", "once_per_bar_close" on TradingView's own tape), so they
385
+ # are ordinary values wherever they appear.
386
+ ALERT_FREQ_MEMBERS = frozenset({
387
+ "freq_all", "freq_once_per_bar", "freq_once_per_bar_close",
388
+ })
389
+
348
390
  # Namespaces whose variable members have no batch-mode data source.
349
391
  # These reads currently emit `std::string("<member>")` via the
350
392
  # unknown-identifier fallthrough — which the analyzer-typed `double`
@@ -359,10 +401,10 @@ UNSUPPORTED_NAMESPACE_VARS: dict[str, str] = {
359
401
  # Codegen supports symbol/timeframe/expression plus gaps/lookahead (read in
360
402
  # _eval_security_* emission and forwarded to register_security_eval).
361
403
  # `currency` is still rejected loudly (it changes the returned values via FX
362
- # conversion, which codegen drops — silently wrong). `ignore_invalid_symbol` is
363
- # a guaranteed no-op here: codegen forces request.security onto the current
364
- # chart symbol (see the "symbol must reference current chart symbol" check),
365
- # which is always valid, so the flag can never change the result — accept+ignore.
404
+ # conversion, which codegen drops — silently wrong). `ignore_invalid_symbol`
405
+ # is registered with a request of another symbol that reads its pinned feed
406
+ # (na for a symbol its facts say is invalid); the chart's own symbol is always
407
+ # valid, where the flag changes nothing.
366
408
  SECURITY_ALLOWED_PARAMS: frozenset[str] = frozenset(
367
409
  {"symbol", "timeframe", "expression", "gaps", "lookahead",
368
410
  "ignore_invalid_symbol",
@@ -393,6 +435,77 @@ SECURITY_ADJUSTMENT_ALLOWED_VALUES: dict[str, frozenset[str]] = {
393
435
  # Identifiers/expressions that resolve to "this script's symbol".
394
436
  SECURITY_CURRENT_SYMBOL_NAMES: frozenset[str] = frozenset({"syminfo.tickerid", "syminfo.ticker"})
395
437
 
438
+ # Built-in namespaces a library import may alias, with their built-in
439
+ # members. TradingView's pine-facade compile of ``import TradingView/ta/7``
440
+ # links no library (``metaInfo.usedLibs`` stays empty) when the script names
441
+ # only built-in ``ta.*`` members, and links it for a library-only name such
442
+ # as ``ta.dema`` (XSYM-DESIGN report 1.3): such an import is a no-op. Every
443
+ # other import stays refused.
444
+ BUILTIN_NAMESPACE_IMPORT_MEMBERS: dict[str, frozenset[str]] = {
445
+ "ta": frozenset(sigs.TA_FUNCTIONS) | TA_PROPERTY_VARIABLES,
446
+ "math": frozenset(sigs.MATH_FUNCTIONS) | frozenset(sigs.MATH_CONSTANTS),
447
+ "str": frozenset(sigs.STR_FUNCTIONS),
448
+ }
449
+
450
+
451
+ def import_spelling(node: ImportStmt) -> str:
452
+ """The import as the refusal names it: ``user/name/version [as alias]``."""
453
+ return f"{node.path} as {node.alias}" if node.alias else node.path
454
+
455
+
456
+ def import_refusal(node: ImportStmt) -> str:
457
+ """The refusal of an import PineForge did not inline, with the reason the
458
+ library inliner recorded when library sources were configured but none
459
+ applies to the script (``pine_libraries``)."""
460
+ message = f"Import is not supported: '{import_spelling(node)}'"
461
+ reason = (node.annotations or {}).get("unresolved_reason")
462
+ return f"{message}: {reason}" if reason else message
463
+
464
+
465
+ def import_is_builtin_namespace_no_op(program: Program, node: ImportStmt) -> bool:
466
+ """``node`` imports a library under the name of a built-in namespace
467
+ (its alias, else its own name) and every member the script names through
468
+ that alias -- in a call, a read or a type -- is one of the namespace's
469
+ built-ins, so the library is never linked."""
470
+ alias = node.alias or node.name
471
+ members = BUILTIN_NAMESPACE_IMPORT_MEMBERS.get(alias or "")
472
+ if node.version is None or members is None:
473
+ return False
474
+ named = re.compile(rf"(?<![\w.]){re.escape(alias)}\.(\w+)")
475
+
476
+ def type_names(value) -> list[str]:
477
+ if isinstance(value, str):
478
+ return [value]
479
+ if isinstance(value, (list, tuple)):
480
+ return [item for item in value if isinstance(item, str)]
481
+ return []
482
+
483
+ stack: list = [program]
484
+ while stack:
485
+ item = stack.pop()
486
+ if isinstance(item, (list, tuple)):
487
+ stack.extend(item)
488
+ continue
489
+ if isinstance(item, dict):
490
+ stack.extend(item.values())
491
+ continue
492
+ if not isinstance(item, (ASTNode, TypeField)):
493
+ continue
494
+ if (isinstance(item, MemberAccess) and isinstance(item.object, Identifier)
495
+ and item.object.name == alias and item.member not in members):
496
+ return False
497
+ hints = [getattr(item, "type_hint", None), getattr(item, "type_name", None)]
498
+ notes = getattr(item, "annotations", None) or {}
499
+ hints += [notes.get("param_type_hints"), notes.get("template_args")]
500
+ for hint in hints:
501
+ for text in type_names(hint):
502
+ if any(m not in members for m in named.findall(text)):
503
+ return False
504
+ for key, value in vars(item).items():
505
+ if key not in ("loc", "annotations"):
506
+ stack.append(value)
507
+ return True
508
+
396
509
 
397
510
  # ---------------------------------------------------------------------------
398
511
  # Helpers
@@ -404,6 +517,18 @@ def _loc(node: ASTNode | None, fallback_file: str) -> SourceLocation:
404
517
  return SourceLocation(file=fallback_file, line=1, col=1, end_col=1)
405
518
 
406
519
 
520
+ def _security_gaps_lookahead(node: FuncCall) -> tuple[ASTNode | None, ASTNode | None]:
521
+ """A ``request.security`` call's gaps and lookahead arguments, positional
522
+ or keyword (None where it leaves one out)."""
523
+ gaps = node.kwargs.get("gaps")
524
+ if gaps is None and len(node.args) > 3:
525
+ gaps = node.args[3]
526
+ lookahead = node.kwargs.get("lookahead")
527
+ if lookahead is None and len(node.args) > 4:
528
+ lookahead = node.args[4]
529
+ return gaps, lookahead
530
+
531
+
407
532
  def _qualified_name(callee: ASTNode) -> tuple[str | None, str | None]:
408
533
  """Return (namespace, function_name) for a call's callee, or (None, None)."""
409
534
  if isinstance(callee, Identifier):
@@ -457,6 +582,9 @@ class SupportChecker:
457
582
  for member, emission in SYMINFO_MEMBER_MAP.items()
458
583
  if "na<" in emission or "get_syminfo_metadata" in emission
459
584
  )
585
+ # Silent-gap fields a run declares as a symbol fact (a lane fact through
586
+ # the syminfo metadata channel): na only when the run declares none.
587
+ _SYMINFO_RUN_FACT_FIELDS: frozenset[str] = frozenset({"mincontract"})
460
588
 
461
589
  def __init__(self, ast: Program, filename: str = "<input>") -> None:
462
590
  self._ast = ast
@@ -498,12 +626,28 @@ class SupportChecker:
498
626
  self._func_tuple_drawing_returns: dict[str, list[bool]] = {}
499
627
  # Track whether we are inside an if/ternary condition expression.
500
628
  self._in_conditional_depth: int = 0
629
+ # Inside a switch's non-default arm, which the checker used to skip:
630
+ # its findings warn (``_err``).
631
+ self._switch_arm_depth: int = 0
501
632
  # Track whether we are inside an argument subtree that legitimately
502
633
  # consumes constant-namespace members: parse-and-skip visual calls
503
634
  # (plot, label.new, ...), the strategy() declaration, and
504
635
  # request.security (barmerge.* gaps/lookahead values). While > 0 the
505
636
  # UNSUPPORTED_CONST_NAMESPACES rejection is suppressed.
506
637
  self._const_arg_ctx_depth: int = 0
638
+ # Inside the arguments of a request PineForge has no data for, whose
639
+ # field constants (``earnings.actual``) name what it would read.
640
+ self._request_field_ctx_depth: int = 0
641
+ # request.footprint calls that are the whole expression of a request
642
+ # of another symbol reading its feed (_lower_foreign_request).
643
+ self._feed_footprints: set[int] = set()
644
+ # Inside a request.security call's arguments (its expression).
645
+ self._security_payload_depth: int = 0
646
+ self._footprints: FootprintValues | bool | None = None
647
+ # Built on the first request with no data: which values reach a trade.
648
+ self._trade_slice: TradeSlice | None = None
649
+ # The helpers a request expression calls (_in_request_expression).
650
+ self._payload_funcs: set[str] | None = None
507
651
  # id()s of Identifier/MemberAccess nodes that are the *callee* of a
508
652
  # FuncCall. A divergent built-in NAME used as a call target (e.g. the
509
653
  # session-aware ``time_close("D")`` function, which is distinct from the
@@ -524,12 +668,36 @@ class SupportChecker:
524
668
  # (below) as well as during the visit, so a rebind that lexically
525
669
  # FOLLOWS the request.security call cannot slip past.
526
670
  self._scalar_rebinds: dict[str, list[ASTNode]] = {}
671
+ self._scalar_rebind_ids: set[int] = set()
672
+ # request.security needs lexical bindings rather than bare names: a
673
+ # function-local `sym := ...` cannot change an unrelated global `sym`.
674
+ # Indexed before the visit so rebinds after a security call count too.
675
+ self._security_symbol_refs: dict[int, object] = {}
676
+ self._security_symbol_defs: dict[object, ASTNode] = {}
677
+ self._security_symbol_rebinds: dict[object, list[ASTNode]] = {}
678
+ # Names declared ``var`` / ``varip`` anywhere: their declaration value
679
+ # is the first bar's only, so it does not describe later bars.
680
+ self._persistent_decl_names: set[str] = set()
681
+ # How many times each name is bound anywhere in the script -- a
682
+ # declaration, a callable parameter, a loop variable, a tuple element
683
+ # -- so a constant argument read through a name is trusted only when
684
+ # that name has exactly one binding and no scope can shadow it.
685
+ self._binding_counts: dict[str, int] = {}
686
+ # User functions by node id, and every call of each by name, so a
687
+ # request.security symbol that is a helper parameter resolves through
688
+ # the arguments of the helper's calls.
689
+ self._user_func_defs: dict[int, FuncDef] = {}
690
+ self._user_func_calls: dict[str, list[FuncCall]] = {}
691
+ self._overloaded_user_funcs: set[str] = set()
527
692
 
528
693
  # -- Public API --
529
694
 
530
695
  def check(self) -> list[Diagnostic]:
531
696
  self._collect_user_definitions(self._ast)
697
+ self._collect_user_calls()
532
698
  self._collect_scalar_rebinds(self._ast)
699
+ self._index_security_symbol_bindings()
700
+ self._count_bindings(self._ast)
533
701
  for stmt in self._ast.body:
534
702
  self._visit(stmt)
535
703
  return self._diagnostics
@@ -552,9 +720,11 @@ class SupportChecker:
552
720
  target = node.target
553
721
  if not isinstance(target, Identifier) or node.value is None:
554
722
  return
555
- recorded = self._scalar_rebinds.setdefault(target.name, [])
556
- if not any(value is node.value for value in recorded):
557
- recorded.append(node.value)
723
+ # A set, not a scan of the list: a name reassigned k times would
724
+ # otherwise cost k**2.
725
+ if id(node.value) not in self._scalar_rebind_ids:
726
+ self._scalar_rebind_ids.add(id(node.value))
727
+ self._scalar_rebinds.setdefault(target.name, []).append(node.value)
558
728
 
559
729
  def _collect_scalar_rebinds(self, node: ASTNode) -> None:
560
730
  """Pre-pass: collect every scalar rebind anywhere in the AST.
@@ -578,6 +748,155 @@ class SupportChecker:
578
748
  if isinstance(item, ASTNode):
579
749
  self._collect_scalar_rebinds(item)
580
750
 
751
+ def _collect_user_calls(self) -> None:
752
+ """Index the user functions and every call of each, anywhere."""
753
+ names: dict[str, int] = {}
754
+ for stmt in self._ast.body:
755
+ if isinstance(stmt, FuncDef):
756
+ self._user_func_defs[id(stmt)] = stmt
757
+ names[stmt.name] = names.get(stmt.name, 0) + 1
758
+ self._overloaded_user_funcs = {n for n, count in names.items() if count > 1}
759
+ stack: list = [self._ast]
760
+ while stack:
761
+ item = stack.pop()
762
+ if isinstance(item, (list, tuple)):
763
+ stack.extend(item)
764
+ continue
765
+ if isinstance(item, dict):
766
+ stack.extend(item.values())
767
+ continue
768
+ if not isinstance(item, ASTNode):
769
+ continue
770
+ if (isinstance(item, FuncCall) and isinstance(item.callee, Identifier)
771
+ and item.callee.name in names):
772
+ self._user_func_calls.setdefault(item.callee.name, []).append(item)
773
+ stack.extend(value for key, value in vars(item).items()
774
+ if key not in ("loc", "annotations"))
775
+
776
+ def _index_security_symbol_bindings(self) -> None:
777
+ """Bind symbol reads and every ``:=`` to their lexical declaration.
778
+
779
+ Declarations in a function, branch or loop body shadow outer names;
780
+ reassignments without a local declaration resolve outwards. The
781
+ whole-block index keeps later rebinds visible to an earlier security
782
+ call, matching the existing conservative check.
783
+ """
784
+ def resolve(name: str, scopes: tuple[dict[str, object], ...]) -> object | None:
785
+ for scope in reversed(scopes):
786
+ if name in scope:
787
+ return scope[name]
788
+ return None
789
+
790
+ def walk_value(value: object, scopes: tuple[dict[str, object], ...]) -> None:
791
+ if isinstance(value, ASTNode):
792
+ walk(value, scopes)
793
+ elif isinstance(value, (list, tuple)):
794
+ for item in value:
795
+ walk_value(item, scopes)
796
+ elif isinstance(value, dict):
797
+ for item in value.values():
798
+ walk_value(item, scopes)
799
+
800
+ def block(statements: list, scopes: tuple[dict[str, object], ...],
801
+ binders: dict[str, object] | None = None) -> None:
802
+ scope = dict(binders or {})
803
+ for stmt in statements:
804
+ if isinstance(stmt, VarDecl) and stmt.name:
805
+ binding = id(stmt)
806
+ scope.setdefault(stmt.name, binding)
807
+ if stmt.value is not None:
808
+ self._security_symbol_defs[binding] = stmt.value
809
+ elif isinstance(stmt, TupleAssign):
810
+ for name in stmt.names:
811
+ scope.setdefault(name, ("tuple", id(stmt), name))
812
+ nested = (*scopes, scope)
813
+ for stmt in statements:
814
+ walk_value(stmt, nested)
815
+
816
+ def walk(node: ASTNode, scopes: tuple[dict[str, object], ...]) -> None:
817
+ if isinstance(node, Identifier):
818
+ binding = resolve(node.name, scopes)
819
+ if binding is not None:
820
+ self._security_symbol_refs[id(node)] = binding
821
+ return
822
+ if isinstance(node, Assignment):
823
+ if isinstance(node.target, Identifier) and node.value is not None:
824
+ binding = resolve(node.target.name, scopes)
825
+ if binding is not None:
826
+ self._security_symbol_rebinds.setdefault(binding, []).append(node.value)
827
+ walk_value(node.target, scopes)
828
+ walk_value(node.value, scopes)
829
+ return
830
+ if isinstance(node, (FuncDef, MethodDef)):
831
+ params = {name: ("param", id(node), name) for name in node.params}
832
+ block(node.body, scopes, params)
833
+ return
834
+ if isinstance(node, IfStmt):
835
+ walk_value(node.condition, scopes)
836
+ block(node.body, scopes)
837
+ block(node.else_body, scopes)
838
+ return
839
+ if isinstance(node, ForStmt):
840
+ for value in (node.start, node.end, node.step):
841
+ walk_value(value, scopes)
842
+ block(node.body, scopes, {node.var: ("loop", id(node), node.var)})
843
+ return
844
+ if isinstance(node, ForInStmt):
845
+ walk_value(node.iterable, scopes)
846
+ names = [node.var] if node.var else list(node.vars or ())
847
+ block(node.body, scopes, {
848
+ name: ("loop", id(node), name) for name in names
849
+ })
850
+ return
851
+ if isinstance(node, WhileStmt):
852
+ walk_value(node.condition, scopes)
853
+ block(node.body, scopes)
854
+ return
855
+ if isinstance(node, SwitchStmt):
856
+ walk_value(node.expr, scopes)
857
+ for case_expr, body in node.cases:
858
+ walk_value(case_expr, scopes)
859
+ block(body, scopes)
860
+ block(node.default_body, scopes)
861
+ return
862
+ for value in vars(node).values():
863
+ walk_value(value, scopes)
864
+
865
+ block(self._ast.body, ())
866
+
867
+ def _count_bindings(self, node) -> None:
868
+ """Pre-pass: count every binding of every name (``_binding_counts``)."""
869
+ if isinstance(node, list):
870
+ for item in node:
871
+ self._count_bindings(item)
872
+ return
873
+ if not isinstance(node, ASTNode):
874
+ return
875
+ names: list = []
876
+ if isinstance(node, VarDecl):
877
+ names = [node.name]
878
+ elif isinstance(node, (FuncDef, MethodDef)):
879
+ names = list(node.params)
880
+ elif isinstance(node, ForStmt):
881
+ names = [node.var]
882
+ elif isinstance(node, ForInStmt):
883
+ names = [node.var] if node.var else list(node.vars or ())
884
+ elif isinstance(node, TupleAssign):
885
+ names = list(node.names)
886
+ for name in names:
887
+ name = name if isinstance(name, str) else getattr(name, "name", None)
888
+ if name:
889
+ self._binding_counts[name] = self._binding_counts.get(name, 0) + 1
890
+ for key, value in vars(node).items():
891
+ if key in ("loc", "annotations"):
892
+ continue
893
+ if key == "cases":
894
+ value = [item for case in value for item in case]
895
+ elif isinstance(value, dict):
896
+ value = list(value.values())
897
+ if isinstance(value, (ASTNode, list)):
898
+ self._count_bindings(value)
899
+
581
900
  def _collect_user_definitions(self, ast: Program) -> None:
582
901
  for stmt in ast.body:
583
902
  if isinstance(stmt, TypeDecl):
@@ -825,7 +1144,10 @@ class SupportChecker:
825
1144
 
826
1145
  def _err(self, node: ASTNode | None, message: str, hint: str | None = None) -> None:
827
1146
  self._diagnostics.append(Diagnostic(
828
- level=Level.ERROR,
1147
+ # The checker never visited a switch's non-default arms: what it
1148
+ # finds there warns, and the arm keeps the lowering it compiled
1149
+ # to (``_visit_SwitchStmt``).
1150
+ level=Level.WARNING if self._switch_arm_depth else Level.ERROR,
829
1151
  phase=Phase.ANALYZER,
830
1152
  location=_loc(node, self._filename),
831
1153
  message=message,
@@ -1006,9 +1328,16 @@ class SupportChecker:
1006
1328
  # (e.g. scale=scale.right, format=format.price).
1007
1329
  self._visit_children_const_ok(node)
1008
1330
 
1331
+ def _visit_ImportStmt(self, node: ImportStmt) -> None:
1332
+ if not import_is_builtin_namespace_no_op(self._ast, node):
1333
+ self._err(node, import_refusal(node))
1334
+
1009
1335
  def _visit_VarDecl(self, node: VarDecl) -> None:
1336
+ self._check_tuple_literal_value(node.value)
1010
1337
  if node.name and node.value is not None:
1011
1338
  self._scalar_defs.setdefault(node.name, node.value)
1339
+ if node.name and (node.is_var or node.is_varip):
1340
+ self._persistent_decl_names.add(node.name)
1012
1341
  if node.type_hint in self._user_types:
1013
1342
  self._var_udt_types[node.name] = node.type_hint
1014
1343
  elif isinstance(node.value, FuncCall):
@@ -1031,22 +1360,28 @@ class SupportChecker:
1031
1360
  vns, vname = _qualified_name(node.value.callee)
1032
1361
  if vname == "new" and vns in _VISUAL_CONTAINER_TYPES:
1033
1362
  self._visual_container_vars.add(node.name)
1034
- if node.is_varip:
1035
- self._err(
1036
- node,
1037
- "varip is not supported in PineForge batch backtests — there "
1038
- "are no intrabar ticks. Codegen would silently demote varip "
1039
- "to var, producing incorrect state accumulation for any "
1040
- "script that relies on tick-level updates.",
1041
- hint=(
1042
- "Replace 'varip' with 'var' if the strategy logic does "
1043
- "not depend on tick-level updates, or run the strategy "
1044
- "in hosted TradingView Studio."
1045
- ),
1046
- )
1363
+ # ``varip`` is supported: a historical bar executes once, so it keeps
1364
+ # its value like ``var``, and codegen leaves it out of the
1365
+ # calc_on_order_fills rollback (``_varip_state_member_names``).
1047
1366
  self._visit_children(node)
1048
1367
 
1049
1368
  def _visit_Assignment(self, node: Assignment) -> None:
1369
+ if isinstance(node.target, TupleLiteral):
1370
+ # ``[p, q] := f()`` reached the C++ as an assignment to a
1371
+ # temporary tuple, a silent no-op. TradingView rejects it
1372
+ # ("Syntax error at input ':='", CE10156): a tuple is declared
1373
+ # with ``=`` only.
1374
+ self._err(
1375
+ node,
1376
+ f"Tuple reassignment [..] {node.op} is not Pine syntax: "
1377
+ "TradingView declares a tuple with '=' and has no tuple "
1378
+ "reassignment.",
1379
+ hint="Declare new names with [a, b] = f(), then reassign "
1380
+ "each variable on its own line.",
1381
+ )
1382
+ self._visit_children(node)
1383
+ return
1384
+ self._check_tuple_literal_value(node.value)
1050
1385
  # ``_scalar_defs`` records only the DECLARATION, so a later ``:=``
1051
1386
  # rebind used to be invisible to the request.security symbol check.
1052
1387
  # (``check`` also pre-collects these; recording here keeps the visit
@@ -1054,7 +1389,50 @@ class SupportChecker:
1054
1389
  self._record_scalar_rebind(node)
1055
1390
  self._visit_children(node)
1056
1391
 
1392
+ def _check_tuple_literal_value(self, value) -> None:
1393
+ """Refuse a tuple literal or a ternary of tuples as a declaration's
1394
+ or reassignment's value.
1395
+
1396
+ TradingView takes a tuple literal only as a function's or an
1397
+ if/switch arm's value and as a request.security expression:
1398
+ ``[a, b] = [close, open]`` and ``t = [close, open]`` are "Syntax
1399
+ error at input '['" (CE10156), ``[a, b] = c ? [x, y] : [y, x]`` is
1400
+ "Ternary operations cannot return tuples" (lab tv --no-note). The
1401
+ tuple declarations reached the C++ as a dropped statement ("/*
1402
+ unsupported tuple assignment */", their names left at 0), the plain
1403
+ declaration a tuple assigned to a double.
1404
+ """
1405
+ if isinstance(value, TupleLiteral):
1406
+ self._err(
1407
+ value,
1408
+ "A tuple literal [..] cannot be a variable's value: TradingView "
1409
+ "rejects it here (Syntax error at input '[', CE10156). A tuple "
1410
+ "is only a function's or an if/switch arm's value or a "
1411
+ "request.security expression.",
1412
+ hint="Declare each variable on its own line (a = x, b = y), or "
1413
+ "return the tuple from a function: f() => [x, y], then "
1414
+ "[a, b] = f().",
1415
+ )
1416
+ elif isinstance(value, Ternary) and self._ternary_yields_tuple(value):
1417
+ self._err(
1418
+ value,
1419
+ "A ternary cannot return a tuple: TradingView rejects "
1420
+ "[a, b] = c ? [..] : [..] (\"Ternary operations cannot return "
1421
+ "tuples\").",
1422
+ hint="Select the tuple with an if or switch: [a, b] = if c, "
1423
+ "each arm ending in its tuple.",
1424
+ )
1425
+
1426
+ @classmethod
1427
+ def _ternary_yields_tuple(cls, node) -> bool:
1428
+ return any(
1429
+ isinstance(arm, TupleLiteral)
1430
+ or (isinstance(arm, Ternary) and cls._ternary_yields_tuple(arm))
1431
+ for arm in (node.true_val, node.false_val)
1432
+ )
1433
+
1057
1434
  def _visit_TupleAssign(self, node: TupleAssign) -> None:
1435
+ self._check_tuple_literal_value(node.value)
1058
1436
  drawing_mask = self._tuple_assign_drawing_mask(node.value)
1059
1437
  if drawing_mask:
1060
1438
  for idx, name in enumerate(node.names):
@@ -1119,6 +1497,42 @@ class SupportChecker:
1119
1497
 
1120
1498
  full = f"{ns}.{name}" if ns else name
1121
1499
 
1500
+ # The footprint another symbol's feed carries: the column of the
1501
+ # request.security whose whole expression this is.
1502
+ if id(node) in self._feed_footprints:
1503
+ node.annotations = {**(node.annotations or {}),
1504
+ FOOTPRINT_COLUMN_ANNOTATION: footprint_column(node)}
1505
+ self._visit_request_arguments(node)
1506
+ return
1507
+
1508
+ # A request PineForge has no data for: na when its value reaches
1509
+ # display sinks only, else its recorded series, or a deferred
1510
+ # refusal when no key names it.
1511
+ if ns == "request" and name in NO_DATA_REQUEST_FUNCS:
1512
+ if name == "footprint":
1513
+ self._lower_no_data_request(node, node, NO_DATA_REQUEST_FUNC[full])
1514
+ else:
1515
+ self._lower_recorded_request(node, NO_DATA_REQUEST_FUNC[full])
1516
+ self._visit_request_arguments(node)
1517
+ return
1518
+
1519
+ # A footprint value is its delta (external_requests.read_footprint_deltas):
1520
+ # every other member is refused by name.
1521
+ footprint = self._footprint_member(node)
1522
+ if footprint is not None:
1523
+ if footprint != "delta":
1524
+ self._err(
1525
+ node, f"footprint.{footprint}(...) is not supported.",
1526
+ hint=("PineForge reads a footprint's delta() only: the "
1527
+ "fp_delta_<ticks>_<va> column of the feed a requests manifest pins "
1528
+ "for the symbol requested."))
1529
+ receiver = node.callee.object
1530
+ if not (isinstance(receiver, Identifier) and receiver.name == "footprint"):
1531
+ self._visit(receiver)
1532
+ for arg in (*node.args, *node.kwargs.values()):
1533
+ self._visit(arg)
1534
+ return
1535
+
1122
1536
  # Hard rejects by full name.
1123
1537
  if full in HARD_REJECT_FUNC:
1124
1538
  self._err(node, f"{full}(...) is not supported.", hint=HARD_REJECT_FUNC[full])
@@ -1164,6 +1578,10 @@ class SupportChecker:
1164
1578
  self._err(node, f"{name}(...) is not implemented yet.", hint=NOT_YET_FUNC[name])
1165
1579
  self._visit_children(node)
1166
1580
  return
1581
+ if (ns is None and name in POSITIONAL_BUILTINS
1582
+ and not self._check_builtin_arguments(node, name)):
1583
+ self._visit_children(node)
1584
+ return
1167
1585
 
1168
1586
  # Bare cosmetic casts (e.g. `color(na)` -> default color). No backtest
1169
1587
  # effect; warn and let codegen emit a benign default color.
@@ -1271,13 +1689,18 @@ class SupportChecker:
1271
1689
  # validated above by _check_request_security and consumed by codegen.
1272
1690
  if full == "request.security":
1273
1691
  self._check_request_security(node)
1274
- self._visit_children_const_ok(node)
1692
+ self._security_payload_depth += 1
1693
+ try:
1694
+ self._visit_children_const_ok(node)
1695
+ finally:
1696
+ self._security_payload_depth -= 1
1275
1697
  return
1276
1698
  # request.security_lower_tf — analyzer/codegen handle parameter validation
1277
1699
  # and element-type rejection (UDT/color/string). Still validate the
1278
1700
  # timeframe literal here so codegen catches malformed TF strings early.
1279
1701
  if full == "request.security_lower_tf":
1280
1702
  self._check_request_security_lower_tf_tf(node)
1703
+ self._check_request_security_lower_tf_symbol(node)
1281
1704
  self._visit_children_const_ok(node)
1282
1705
  return
1283
1706
  if ns == "request":
@@ -1315,6 +1738,18 @@ class SupportChecker:
1315
1738
  self._err(node, f"ta.{name}(...) is not implemented in PineForge runtime.")
1316
1739
  self._visit_children(node)
1317
1740
  return
1741
+ if ns == "ta":
1742
+ self._check_ta_call(node, name)
1743
+ if name == "ema":
1744
+ self._warn(
1745
+ node,
1746
+ "ta.ema initial warmup is approximated: PineForge may "
1747
+ "return a finite value before TradingView does.",
1748
+ hint="A covered length-20 TradingView tape first returns "
1749
+ "a finite EMA at bar 19; standalone PineForge EMA "
1750
+ "is finite at bar 0. Its established lowering stays "
1751
+ "in place pending per-call-site population parity.",
1752
+ )
1318
1753
  if ns == "math" and name not in SUPPORTED_MATH:
1319
1754
  self._err(node, f"math.{name}(...) is not implemented in PineForge runtime.")
1320
1755
  self._visit_children(node)
@@ -1350,7 +1785,8 @@ class SupportChecker:
1350
1785
  self._warn(
1351
1786
  node,
1352
1787
  f"color.{name}(...) has no effect in PineForge backtests "
1353
- f"(visual only); it emits a default color.",
1788
+ f"(visual only); it evaluates its arguments and emits a "
1789
+ f"default color.",
1354
1790
  )
1355
1791
  self._visit_children(node)
1356
1792
  return
@@ -1507,6 +1943,30 @@ class SupportChecker:
1507
1943
  for stmt in node.else_body:
1508
1944
  self._visit(stmt)
1509
1945
 
1946
+ def _visit_SwitchStmt(self, node: SwitchStmt) -> None:
1947
+ """Visit a switch: its selector, every arm's condition and block, and
1948
+ its default block. ``cases`` holds ``(condition, block)`` tuples,
1949
+ which the generic child walk does not enter, so every arm but the
1950
+ default went unchecked: a request of another symbol in one kept no
1951
+ lowering (``TradingView/Request/3``'s ``cryptoDerivativeMetric``).
1952
+ Such an arm gets its lowerings now; what the checker would refuse
1953
+ there warns instead, so a script that compiled keeps compiling."""
1954
+ self._visit(node.expr)
1955
+ self._switch_arm_depth += 1
1956
+ try:
1957
+ for case_expr, body in node.cases:
1958
+ if node.expr is None:
1959
+ self._in_conditional_depth += 1
1960
+ self._visit(case_expr)
1961
+ if node.expr is None:
1962
+ self._in_conditional_depth -= 1
1963
+ for stmt in body:
1964
+ self._visit(stmt)
1965
+ finally:
1966
+ self._switch_arm_depth -= 1
1967
+ for stmt in node.default_body:
1968
+ self._visit(stmt)
1969
+
1510
1970
  def _visit_Ternary(self, node: Ternary) -> None:
1511
1971
  """Visit ternary; mark the condition expression as conditional context."""
1512
1972
  self._in_conditional_depth += 1
@@ -1564,13 +2024,15 @@ class SupportChecker:
1564
2024
  lambda k, v: f"{k[0]}.{k[1]}: {v}",
1565
2025
  ):
1566
2026
  return
1567
- # Namespace-wide variable rejections (e.g. dividends.*, earnings.*).
1568
- if isinstance(node.object, Identifier) and self._reject_if_in(
1569
- UNSUPPORTED_NAMESPACE_VARS,
1570
- node.object.name,
1571
- node,
1572
- lambda k, v: f"{k}.{node.member}: {v}",
1573
- ):
2027
+ # Namespace-wide variable rejections (e.g. dividends.*, earnings.*),
2028
+ # but for the field argument of the request that reads them.
2029
+ if (isinstance(node.object, Identifier) and self._request_field_ctx_depth == 0
2030
+ and self._reject_if_in(
2031
+ UNSUPPORTED_NAMESPACE_VARS,
2032
+ node.object.name,
2033
+ node,
2034
+ lambda k, v: f"{k}.{node.member}: {v}",
2035
+ )):
1574
2036
  return
1575
2037
  # Constant-only namespace members (plot.style_*, text.align_*,
1576
2038
  # barmerge.*, alert.freq_*, ...) used as FREE EXPRESSIONS. Inside
@@ -1579,6 +2041,10 @@ class SupportChecker:
1579
2041
  if (
1580
2042
  self._const_arg_ctx_depth == 0
1581
2043
  and isinstance(node.object, Identifier)
2044
+ and not (node.object.name == "alert"
2045
+ and node.member in ALERT_FREQ_MEMBERS)
2046
+ and not (node.object.name == "barmerge"
2047
+ and node.member in BARMERGE_MEMBERS)
1582
2048
  and self._reject_if_in(
1583
2049
  UNSUPPORTED_CONST_NAMESPACES,
1584
2050
  node.object.name,
@@ -1595,6 +2061,14 @@ class SupportChecker:
1595
2061
  if isinstance(node.object, Identifier) and node.object.name == "syminfo":
1596
2062
  if node.member not in SUPPORTED_SYMINFO:
1597
2063
  self._err(node, f"syminfo.{node.member} is not implemented in PineForge runtime.")
2064
+ elif node.member in self._SYMINFO_RUN_FACT_FIELDS:
2065
+ # Read from the symbol fact the run declares; na without one.
2066
+ self._warn(
2067
+ node,
2068
+ f"syminfo.{node.member} is na unless the run declares the "
2069
+ f"symbol's {node.member} (syminfo metadata "
2070
+ f"\"{node.member}\", a lane fact).",
2071
+ )
1598
2072
  elif node.member in self._SYMINFO_SILENT_GAP_FIELDS:
1599
2073
  # These fields silently return na in current PineForge. Warn on
1600
2074
  # EVERY read — not just inside an if/ternary condition — because
@@ -1653,12 +2127,22 @@ class SupportChecker:
1653
2127
  elif "symbol" in node.kwargs:
1654
2128
  symbol_node = node.kwargs["symbol"]
1655
2129
 
1656
- if symbol_node is not None and not self._is_current_symbol_expr(symbol_node):
1657
- self._err(
1658
- symbol_node,
1659
- "request.security symbol must reference the current chart symbol.",
1660
- hint="Use syminfo.tickerid or syminfo.ticker; PineForge backtests do not load alternate symbols.",
1661
- )
2130
+ if symbol_node is not None:
2131
+ scoped_safe = self._is_current_symbol_expr(symbol_node)
2132
+ legacy_safe = self._is_current_symbol_expr(symbol_node, legacy_names=True)
2133
+ if not scoped_safe and not legacy_safe:
2134
+ # Another symbol: its pinned feed, when one can be read.
2135
+ self._lower_foreign_request(node, symbol_node)
2136
+ elif not scoped_safe or not self._is_current_symbol_expr(
2137
+ symbol_node, require_all_paths=True
2138
+ ):
2139
+ if not self._lower_alternate_symbol(node, symbol_node):
2140
+ self._warn(
2141
+ symbol_node,
2142
+ "request.security symbol can select an alternate symbol, but "
2143
+ "PineForge always loads the current chart symbol.",
2144
+ hint="Every reachable symbol value must resolve to syminfo.tickerid or syminfo.ticker for exact results.",
2145
+ )
1662
2146
 
1663
2147
  # timeframe literal-format check (positional [1] or kwarg).
1664
2148
  tf_node = node.kwargs.get("timeframe")
@@ -1666,21 +2150,20 @@ class SupportChecker:
1666
2150
  tf_node = node.args[1]
1667
2151
  self._check_tf_literal(tf_node, "request.security")
1668
2152
 
1669
- # gaps / lookahead value-shape check (positional or kwarg).
1670
- gaps_node = node.kwargs.get("gaps")
1671
- if gaps_node is None and len(node.args) > 3:
1672
- gaps_node = node.args[3]
1673
- if gaps_node is not None and not self._is_barmerge_member(gaps_node, "gaps_on", "gaps_off"):
2153
+ # gaps / lookahead value-shape check (positional or kwarg). A request
2154
+ # that reads no data (lowered above) never reads them.
2155
+ reads_data = (node.annotations or {}).get(LOWERING_ANNOTATION) not in (
2156
+ "inert", "unpinned")
2157
+ gaps_node, lookahead_node = _security_gaps_lookahead(node)
2158
+ if (reads_data and gaps_node is not None
2159
+ and not self._is_barmerge_member(gaps_node, "gaps_on", "gaps_off")):
1674
2160
  self._err(
1675
2161
  gaps_node,
1676
2162
  "request.security gaps must be barmerge.gaps_on or barmerge.gaps_off.",
1677
2163
  hint="Codegen only recognizes the barmerge.gaps_* literal; other values are silently treated as gaps_off.",
1678
2164
  )
1679
2165
 
1680
- lookahead_node = node.kwargs.get("lookahead")
1681
- if lookahead_node is None and len(node.args) > 4:
1682
- lookahead_node = node.args[4]
1683
- if lookahead_node is not None and not self._is_barmerge_member(
2166
+ if reads_data and lookahead_node is not None and not self._is_barmerge_member(
1684
2167
  lookahead_node, "lookahead_on", "lookahead_off"
1685
2168
  ):
1686
2169
  self._err(
@@ -1716,6 +2199,260 @@ class SupportChecker:
1716
2199
  # wrong-result bug. See SECURITY_ADJUSTMENT_ALLOWED_VALUES.
1717
2200
  self._check_security_adjustment_kwargs(node)
1718
2201
 
2202
+ def _lower_no_data_request(self, node: FuncCall, at: ASTNode, why: str,
2203
+ reason: str | None | object = ...) -> None:
2204
+ """A request PineForge has no data for: lowered to na, with a
2205
+ warning, when its value reaches display and alert sinks only; else a
2206
+ deferred refusal whose first read stops the run
2207
+ (``external_requests``). ``reason`` is the trade slice's, when the
2208
+ caller has it."""
2209
+ if self._trade_slice is None:
2210
+ self._trade_slice = TradeSlice(self._ast)
2211
+ if reason is ...:
2212
+ reason = self._trade_slice.reason(node)
2213
+ lowering = "inert" if reason is None else "unpinned"
2214
+ node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: lowering}
2215
+ if reason is None:
2216
+ self._warn(
2217
+ at,
2218
+ f"{spell_call(node)}: value reaches only display/alert sinks; "
2219
+ "lowered to na; trades are unaffected.",
2220
+ hint=f"{why} The value it lowers to reaches alerts, plots and tables only.",
2221
+ )
2222
+ else:
2223
+ self._warn(
2224
+ at,
2225
+ f"{spell_call(node)}: no data is pinned for this request; the run "
2226
+ "stops with an error where its value is read.",
2227
+ hint=f"{why} Its value can reach a trade: {reason}.",
2228
+ )
2229
+
2230
+ def _lower_foreign_request(self, node: FuncCall, symbol_node: ASTNode) -> None:
2231
+ """``request.security`` of another symbol. Its value reaches display
2232
+ and alert sinks only: na, as before. Otherwise it reads the feed the
2233
+ probe's requests manifest pins for the symbol (``FEED_LOWERING``),
2234
+ with its first read stopping the run when none is installed; a shape
2235
+ registration cannot key keeps the deferred refusal."""
2236
+ if self._trade_slice is None:
2237
+ self._trade_slice = TradeSlice(self._ast)
2238
+ reason = self._trade_slice.reason(node)
2239
+ blocker = None if reason is None else self._foreign_feed_blocker(node, symbol_node)
2240
+ if reason is None or blocker is not None:
2241
+ self._lower_no_data_request(
2242
+ node, symbol_node,
2243
+ "PineForge backtests load the chart's symbol only "
2244
+ "(syminfo.tickerid, syminfo.ticker)"
2245
+ + (f", and reads another symbol's feed only when {blocker}." if blocker
2246
+ else "."),
2247
+ reason=reason)
2248
+ return
2249
+ self._mark_feed(node)
2250
+ self._feed_warning(
2251
+ node, symbol_node,
2252
+ f"{spell_call(node)}: another symbol's bars, read from the feed the requests "
2253
+ "manifest pins for it; with none installed, the run stops with an error where "
2254
+ "its value is read.",
2255
+ hint=("PineForge runs the expression on that symbol's own bars (history, ta.* "
2256
+ "and syminfo.* in its context) and merges them by time as TradingView "
2257
+ "does; a symbol equal to the chart's reads the chart."),
2258
+ )
2259
+
2260
+ def _feed_warning(self, node: FuncCall, at: ASTNode, message: str, hint: str) -> None:
2261
+ """Warn that ``node`` reads a feed -- or, when its symbol or
2262
+ timeframe reaches it through a helper's parameters, leave the warning
2263
+ to ``security_contexts``, which keys it on every call path or keeps
2264
+ its earlier lowering (``FEED_WARNING_ANNOTATION``)."""
2265
+ index = self._trade_slice.index
2266
+ symbol, tf = node.args[:2] if len(node.args) >= 2 else (
2267
+ node.args[0] if node.args else node.kwargs.get("symbol"),
2268
+ node.kwargs.get("timeframe"))
2269
+ if index.depends_on_scope(symbol) or index.depends_on_scope(tf):
2270
+ node.annotations = {**node.annotations,
2271
+ FEED_WARNING_ANNOTATION: (message, hint,
2272
+ _loc(at, self._filename))}
2273
+ return
2274
+ self._warn(at, message, hint=hint)
2275
+
2276
+ def _mark_feed(self, node: FuncCall) -> None:
2277
+ node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: FEED_LOWERING}
2278
+ payload = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
2279
+ if footprint_column(payload) is not None:
2280
+ self._feed_footprints.add(id(payload))
2281
+
2282
+ def _lower_alternate_symbol(self, node: FuncCall, symbol_node: ASTNode) -> bool:
2283
+ """A symbol that can select the chart's or another symbol's, and
2284
+ whose value can reach a trade: registered by the string the run
2285
+ computes, the chart's reading the chart and another's its pinned feed
2286
+ (never the chart's bars in its place). False, keeping the chart's
2287
+ lowering with its warning, for one whose value reaches display sinks
2288
+ only or that registration cannot key."""
2289
+ if self._trade_slice is None:
2290
+ self._trade_slice = TradeSlice(self._ast)
2291
+ if (self._trade_slice.reason(node) is None
2292
+ or self._foreign_feed_blocker(node, symbol_node) is not None):
2293
+ return False
2294
+ self._mark_feed(node)
2295
+ node.annotations = {**node.annotations, CHART_FALLBACK_ANNOTATION: True}
2296
+ payload = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
2297
+ reads = ("a footprint reads the feed the requests manifest pins for each symbol, the "
2298
+ "chart's too (the chart's bars carry no footprint)"
2299
+ if footprint_column(payload) is not None else
2300
+ "the chart's reads the chart and another symbol's the feed the requests "
2301
+ "manifest pins for it")
2302
+ self._feed_warning(
2303
+ node, symbol_node,
2304
+ f"{spell_call(node)}: its symbol can select another symbol: registered by the "
2305
+ f"string the run computes, {reads}; with none installed, the run stops with an "
2306
+ "error where its value is read.",
2307
+ hint=("It read the chart's bars for every symbol: another symbol's value never "
2308
+ "comes from the chart."),
2309
+ )
2310
+ return True
2311
+
2312
+ def _lower_recorded_request(self, node: FuncCall, why: str) -> None:
2313
+ """``request.earnings`` / ``dividends`` / ``splits`` / ``financial``.
2314
+ Its value reaches display and alert sinks only: na, as before.
2315
+ Otherwise it reads the series TradingView returned per chart bar,
2316
+ which the probe's requests manifest records under the request's key
2317
+ (``RECORDED_LOWERING``), its reads stopping the run when no series
2318
+ is installed; a spelling no key names keeps the deferred refusal."""
2319
+ if self._trade_slice is None:
2320
+ self._trade_slice = TradeSlice(self._ast)
2321
+ reason = self._trade_slice.reason(node)
2322
+ parts, blocker = recorded_key(node) if reason is not None else (None, None)
2323
+ if reason is not None and blocker is None and self._in_request_expression(node):
2324
+ blocker = "it is read outside a request.security expression"
2325
+ if reason is None or blocker is not None:
2326
+ self._lower_no_data_request(
2327
+ node, node, why + (f" A recorded series is read when {blocker}." if blocker
2328
+ else ""),
2329
+ reason=reason)
2330
+ return
2331
+ node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: RECORDED_LOWERING}
2332
+ key = (f"{parts['fn']}|<its symbol>|{parts['field']}|{parts['period']}|"
2333
+ f"gaps_{parts['gaps']}|lookahead_{parts['lookahead']}")
2334
+ self._warn(
2335
+ node,
2336
+ f"{spell_call(node)}: TradingView's values per chart bar, read from the series the "
2337
+ f"requests manifest records under {key}; with none installed, the run stops "
2338
+ "with an error where its value is read.",
2339
+ hint=("PineForge replays the values TradingView returned on the chart's own bars "
2340
+ "(chart_open_ms,value): a bar the tape has no row for reads na."),
2341
+ )
2342
+
2343
+ def _footprint_member(self, node: FuncCall) -> str | None:
2344
+ """The member ``node`` calls on a footprint value (``fp.delta()``,
2345
+ ``footprint.poc(fp)``), else None."""
2346
+ if not isinstance(node.callee, MemberAccess):
2347
+ return None
2348
+ if self._footprints is None:
2349
+ # Most scripts name no footprint: index none for them.
2350
+ mentions = any(
2351
+ (isinstance(n, Identifier) and n.name == "footprint")
2352
+ or (isinstance(n, MemberAccess) and n.member == "footprint")
2353
+ or (isinstance(n, VarDecl) and str(n.type_hint or "").strip() == "footprint")
2354
+ for n in _walk_nodes(self._ast))
2355
+ if not mentions:
2356
+ self._footprints = False
2357
+ else:
2358
+ if self._trade_slice is None:
2359
+ self._trade_slice = TradeSlice(self._ast)
2360
+ self._footprints = FootprintValues(self._trade_slice.index)
2361
+ if self._footprints is False:
2362
+ return None
2363
+ read = self._footprints.member(node)
2364
+ return read[0] if read is not None else None
2365
+
2366
+ def _foreign_feed_blocker(self, node: FuncCall, symbol_node: ASTNode) -> str | None:
2367
+ """Why ``node`` cannot read another symbol's feed, or None: the
2368
+ engine registers a feed site for ``request.security`` alone, keyed by
2369
+ the symbol string and ``ignore_invalid_symbol`` as registration
2370
+ computes them before the first bar (a symbol reaching the request
2371
+ through a helper's parameters is resolved per call path, and falls
2372
+ back there), with no other request in its expression."""
2373
+ if _qualified_name(node.callee) != ("request", "security"):
2374
+ return "it is request.security"
2375
+ if self._in_request_expression(node):
2376
+ return "it is outside another request's expression"
2377
+ index = self._trade_slice.index
2378
+ if index.depends_on_scope(symbol_node):
2379
+ owner = index.owner.get(id(node))
2380
+ if owner not in index.funcs or owner in index.overloaded:
2381
+ # A method's parameter, a block's local: no call path to
2382
+ # resolve it through (``security_contexts``).
2383
+ return "registration computes its symbol before the first bar"
2384
+ elif not index.registration_value(symbol_node):
2385
+ return "registration computes its symbol before the first bar"
2386
+ tf = node.args[1] if len(node.args) > 1 else node.kwargs.get("timeframe")
2387
+ if (tf is not None and not index.depends_on_scope(tf)
2388
+ and not index.registers_timeframe(tf)):
2389
+ return "registration computes its timeframe before the first bar"
2390
+ ignore = node.kwargs.get("ignore_invalid_symbol")
2391
+ if ignore is not None and not index.registration_value(ignore):
2392
+ return "registration computes its ignore_invalid_symbol before the first bar"
2393
+ gaps, lookahead = _security_gaps_lookahead(node)
2394
+ if not (gaps is None or self._is_barmerge_member(gaps, "gaps_on", "gaps_off")) or not (
2395
+ lookahead is None
2396
+ or self._is_barmerge_member(lookahead, "lookahead_on", "lookahead_off")):
2397
+ # Registration reads them as the constants they are written as.
2398
+ return "its gaps and lookahead are barmerge constants"
2399
+ payload = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
2400
+ if isinstance(payload, FuncCall) and _qualified_name(payload.callee) == (
2401
+ "request", "footprint"):
2402
+ # The feed's footprint column: its name spells the literal ticks
2403
+ # per row and value-area percent.
2404
+ if footprint_column(payload) is None:
2405
+ return ("its request.footprint states ticks_per_row and va_percent as "
2406
+ "literal whole numbers")
2407
+ return None
2408
+ helpers = self._helpers_called(payload)
2409
+ for inner in _walk_nodes([payload, *(index.funcs[name] for name in helpers)]):
2410
+ if isinstance(inner, FuncCall) and _qualified_name(inner.callee)[0] == "request":
2411
+ return "its expression holds no request of its own"
2412
+ return None
2413
+
2414
+ def _helpers_called(self, value) -> set[str]:
2415
+ """The helpers ``value`` calls, directly or through further helpers."""
2416
+ index = self._trade_slice.index
2417
+
2418
+ def callees(item) -> set[str]:
2419
+ return {n.callee.name for n in _walk_nodes(item)
2420
+ if isinstance(n, FuncCall) and id(n) in index.call_ids}
2421
+
2422
+ found: set[str] = set()
2423
+ pending = callees(value)
2424
+ while pending:
2425
+ name = pending.pop()
2426
+ if name not in found:
2427
+ found.add(name)
2428
+ pending |= callees(index.funcs[name]) - found
2429
+ return found
2430
+
2431
+ def _in_request_expression(self, node: FuncCall) -> bool:
2432
+ """``node`` is evaluated inside a request's expression: written in it
2433
+ (a request's arguments are its only children the checker visits
2434
+ under ``_security_payload_depth``), or in a helper that expression
2435
+ calls, whose body runs inside the request's evaluator."""
2436
+ if self._security_payload_depth:
2437
+ return True
2438
+ if self._trade_slice is None:
2439
+ self._trade_slice = TradeSlice(self._ast)
2440
+ index = self._trade_slice.index
2441
+ if self._payload_funcs is None:
2442
+ self._payload_funcs = self._helpers_called([
2443
+ request.args[2] if len(request.args) > 2 else request.kwargs.get("expression")
2444
+ for requests in index.requests.values() for request in requests])
2445
+ return index.owner.get(id(node)) in self._payload_funcs
2446
+
2447
+ def _visit_request_arguments(self, node: FuncCall) -> None:
2448
+ """A no-data request's arguments: ``barmerge.*`` and its field
2449
+ constants (``earnings.actual``) are what it reads."""
2450
+ self._request_field_ctx_depth += 1
2451
+ try:
2452
+ self._visit_children_const_ok(node)
2453
+ finally:
2454
+ self._request_field_ctx_depth -= 1
2455
+
1719
2456
  def _check_security_adjustment_kwargs(self, node: FuncCall) -> None:
1720
2457
  """Reject request.security adjustment kwargs that the engine drops."""
1721
2458
  for kw_name, allowed in SECURITY_ADJUSTMENT_ALLOWED_VALUES.items():
@@ -1741,6 +2478,195 @@ class SupportChecker:
1741
2478
  hint=f"Allowed values: {sorted(allowed)}.",
1742
2479
  )
1743
2480
 
2481
+ # -- ta.* call arguments --
2482
+
2483
+ def _check_ta_call(self, node: FuncCall, name: str) -> None:
2484
+ """Bind the call's arguments to TradingView's signature, then flag (or,
2485
+ for a spelling that never compiled, refuse) a parameter value the
2486
+ engine's class cannot compute."""
2487
+ if not self._check_ta_arguments(node, name):
2488
+ return
2489
+ if name == "vwap":
2490
+ self._check_ta_vwap_anchor(node)
2491
+ elif name == "alma":
2492
+ self._check_ta_alma_floor(node)
2493
+ elif name in ("kc", "kcw"):
2494
+ self._check_ta_keltner_true_range(node, name)
2495
+ elif name == "pivot_point_levels":
2496
+ self._check_ta_pivot_point_levels(node)
2497
+
2498
+ @staticmethod
2499
+ def _ta_signature_text(name: str) -> str:
2500
+ """TradingView's signature(s) of ``ta.<name>``, optional parameters
2501
+ with their defaults."""
2502
+ def spell(sig) -> str:
2503
+ parts = []
2504
+ for p in sig.params:
2505
+ if p.default is None:
2506
+ parts.append(p.name)
2507
+ elif isinstance(p.default, bool):
2508
+ parts.append(f"{p.name} = {'true' if p.default else 'false'}")
2509
+ else:
2510
+ parts.append(f"{p.name} = {p.default}")
2511
+ return f"ta.{name}({', '.join(parts)})"
2512
+ return " or ".join(spell(sig) for sig in sigs.TA_FUNCTIONS[name].signatures)
2513
+
2514
+ @staticmethod
2515
+ def _ta_binding_problem(sig, node: FuncCall):
2516
+ """Why ``node``'s arguments do not bind to ``sig``, as ``(rank, message,
2517
+ node to point at)``, or None when they bind. A lower rank is the more
2518
+ useful report when no overload binds."""
2519
+ names = [p.name for p in sig.params]
2520
+ if len(node.args) > len(names):
2521
+ return ((2, 0), f"takes at most {len(names)} arguments, got {len(node.args)}",
2522
+ node.args[len(names)])
2523
+ for key, value in node.kwargs.items():
2524
+ if key not in names:
2525
+ return (3, 0), f"has no parameter '{key}'", value
2526
+ if names.index(key) < len(node.args):
2527
+ return (1, 0), f"got '{key}' twice, by position and by keyword", value
2528
+ bound = set(names[:len(node.args)]) | set(node.kwargs)
2529
+ missing = [p.name for p in sig.params if p.default is None and p.name not in bound]
2530
+ if missing:
2531
+ listed = ", ".join(f"'{m}'" for m in missing)
2532
+ noun = "arguments" if len(missing) > 1 else "argument"
2533
+ return (0, len(missing)), f"is missing its {noun} {listed}", None
2534
+ return None
2535
+
2536
+ def _check_builtin_arguments(self, node: FuncCall, name: str) -> bool:
2537
+ """Refuse an ``nz`` / ``fixnan`` call whose arguments bind to none of
2538
+ TradingView's signatures -- ``nz(source)``, ``nz(source,
2539
+ replacement)``, ``fixnan(source)`` -- as TradingView does ("The nz
2540
+ function does not have an argument with the name x"). A call that
2541
+ binds is rewritten positionally by ``builtin_keywords``; before, a
2542
+ keyword crashed the codegen (IndexError) or was dropped. Returns True
2543
+ when the arguments bind.
2544
+ """
2545
+ func = sigs.BUILTIN_FUNCTIONS[name]
2546
+ problems = []
2547
+ for sig in func.signatures:
2548
+ problem = self._ta_binding_problem(sig, node)
2549
+ if problem is None:
2550
+ return True
2551
+ problems.append((problem[0], -len(sig.params), problem[1], problem[2]))
2552
+ _rank, _width, message, at = min(problems, key=lambda p: (p[0], p[1]))
2553
+ spelled = " or ".join(
2554
+ f"{name}({', '.join(p.name for p in sig.params)})" for sig in func.signatures)
2555
+ self._err(
2556
+ expr_start(at if at is not None else node),
2557
+ f"{name} {message} (TradingView: {spelled}).",
2558
+ hint="Use the parameters of TradingView's signature.",
2559
+ )
2560
+ return False
2561
+
2562
+ def _check_ta_arguments(self, node: FuncCall, name: str) -> bool:
2563
+ """Refuse a ``ta.*`` call whose arguments bind to none of TradingView's
2564
+ signatures (``signatures.TA_FUNCTIONS``), as TradingView does.
2565
+
2566
+ The analyzer routes the arguments by position, each to a constructor or
2567
+ ``compute()`` slot. An argument that binds to no parameter used to be
2568
+ dropped (a misspelt or foreign keyword, a keyword given after the same
2569
+ positional), shifted into the next constructor slot (a keyword skipping
2570
+ a required parameter: ``ta.alma(close, 9, sigma=4)`` built
2571
+ ``ALMA(9, 4)``) or passed to a ``compute()`` overload that does not
2572
+ exist (one too many, or a missing required one): a silently wrong
2573
+ indicator or a C++ compile failure. Returns True when they bind.
2574
+ """
2575
+ func = sigs.TA_FUNCTIONS.get(name)
2576
+ if func is None:
2577
+ return True
2578
+ if name == "vwap" and not node.args and not node.kwargs:
2579
+ # ``ta.vwap()`` reads like the bare ``ta.vwap`` property (the VWAP
2580
+ # of hlc3), which is what it has always compiled to.
2581
+ return True
2582
+ problems = []
2583
+ for sig in func.signatures:
2584
+ problem = self._ta_binding_problem(sig, node)
2585
+ if problem is None:
2586
+ return True
2587
+ problems.append((problem[0], -len(sig.params), problem[1], problem[2]))
2588
+ _rank, _width, message, at = min(problems, key=lambda p: (p[0], p[1]))
2589
+ self._err(
2590
+ expr_start(at if at is not None else node),
2591
+ f"ta.{name} {message} (TradingView: {self._ta_signature_text(name)}).",
2592
+ hint="Use the parameters of TradingView's signature.",
2593
+ )
2594
+ return False
2595
+
2596
+ def _ta_argument(self, node: FuncCall, name: str, param: str):
2597
+ """The argument bound to ``param`` (the call's arguments bind to the
2598
+ widest signature naming it), or None when it is omitted."""
2599
+ if param in node.kwargs:
2600
+ return node.kwargs[param]
2601
+ for sig in sorted(sigs.TA_FUNCTIONS[name].signatures, key=lambda s: -len(s.params)):
2602
+ names = [p.name for p in sig.params]
2603
+ if param in names:
2604
+ index = names.index(param)
2605
+ return node.args[index] if index < len(node.args) else None
2606
+ return None
2607
+
2608
+ def _is_constant_bool(self, node, value: bool, _seen: frozenset = frozenset()) -> bool:
2609
+ """``node`` is the literal ``value``, directly or through a name bound
2610
+ exactly once in the script -- never reassigned, not ``var`` -- to it."""
2611
+ if isinstance(node, BoolLiteral):
2612
+ return node.value is value
2613
+ if isinstance(node, Identifier) and node.name not in _seen:
2614
+ if (self._binding_counts.get(node.name) != 1
2615
+ or node.name in self._scalar_rebinds
2616
+ or node.name in self._persistent_decl_names):
2617
+ return False
2618
+ definition = self._scalar_defs.get(node.name)
2619
+ return definition is not None and self._is_constant_bool(
2620
+ definition, value, _seen | {node.name})
2621
+ return False
2622
+
2623
+ def _check_ta_alma_floor(self, node: FuncCall) -> None:
2624
+ """TA1's ``ta::ALMA`` accepts the Pine ``floor`` input exactly.
2625
+
2626
+ The generated compatibility shim keeps the same source compilable
2627
+ against an older engine and drops the argument there.
2628
+ """
2629
+ return
2630
+
2631
+ def _check_ta_keltner_true_range(self, node: FuncCall, name: str) -> None:
2632
+ """TA1's KC/KCW classes accept ``useTrueRange`` exactly."""
2633
+ return
2634
+
2635
+ def _check_ta_pivot_point_levels(self, node: FuncCall) -> None:
2636
+ """TA1's ``PivotPointLevels`` accepts anchor and developing exactly."""
2637
+ return
2638
+
2639
+ # -- ta.vwap anchor --
2640
+
2641
+ # Timeframe strings naming the symbol's daily bar: ``ta.vwap``'s default
2642
+ # anchor is ``timeframe.change("1D")``.
2643
+ _VWAP_DAILY_ANCHOR_TFS = frozenset({"D", "1D"})
2644
+
2645
+ def _check_ta_vwap_anchor(self, node: FuncCall) -> None:
2646
+ """TA1's anchored VWAP classes accept every Pine anchor expression.
2647
+
2648
+ The code generator keeps the historical class for the default daily
2649
+ anchor and emits the compatibility shim for every other anchor.
2650
+ """
2651
+ return
2652
+
2653
+ def _is_daily_timeframe_change(self, node: ASTNode, _seen: frozenset = frozenset()) -> bool:
2654
+ """``timeframe.change("1D")`` / ``("D")``, directly or through a
2655
+ never-reassigned, non-``var`` binding of it."""
2656
+ if isinstance(node, FuncCall):
2657
+ ns, fname = _qualified_name(node.callee)
2658
+ if (ns, fname) != ("timeframe", "change") or len(node.args) + len(node.kwargs) != 1:
2659
+ return False
2660
+ tf = node.args[0] if node.args else node.kwargs.get("timeframe")
2661
+ return isinstance(tf, StringLiteral) and tf.value in self._VWAP_DAILY_ANCHOR_TFS
2662
+ if isinstance(node, Identifier) and node.name not in _seen:
2663
+ if node.name in self._scalar_rebinds or node.name in self._persistent_decl_names:
2664
+ return False
2665
+ definition = self._scalar_defs.get(node.name)
2666
+ return definition is not None and self._is_daily_timeframe_change(
2667
+ definition, _seen | {node.name})
2668
+ return False
2669
+
1744
2670
  def _is_barmerge_member(self, node: ASTNode, *allowed: str) -> bool:
1745
2671
  if not isinstance(node, MemberAccess):
1746
2672
  return False
@@ -1748,51 +2674,113 @@ class SupportChecker:
1748
2674
  return False
1749
2675
  return node.member in allowed
1750
2676
 
1751
- def _is_current_symbol_expr(self, node: ASTNode, _seen: set[str] | None = None) -> bool:
1752
- if _seen is None:
1753
- _seen = set()
2677
+ def _is_current_symbol_expr(
2678
+ self, node: ASTNode, _seen: frozenset[object] = frozenset(),
2679
+ *, require_all_paths: bool = False, legacy_names: bool = False,
2680
+ ) -> bool:
1754
2681
  chain = _resolve_member_chain(node)
1755
2682
  if chain in SECURITY_CURRENT_SYMBOL_NAMES:
1756
2683
  return True
1757
- # ticker.inherit(symbol, ...) / ticker.standard(symbol) are passthrough,
1758
- # and ticker.heikinashi(symbol) is the chart's OWN symbol with a causal
1759
- # Heikin-Ashi candle transform (the engine applies it inside the security
1760
- # eval via register_security_eval's heikinashi flag — no alternate symbol
1761
- # is loaded). All allow a current-symbol first argument; a non-current
1762
- # arg is genuine cross-symbol construction and still falls through.
2684
+ # ticker.inherit(from_tickerid, symbol) / ticker.standard(symbol) are
2685
+ # passthrough of their symbol (ticker_symbol_arg: inherit names it
2686
+ # second), and ticker.heikinashi(symbol) is the chart's OWN symbol with
2687
+ # a causal Heikin-Ashi candle transform (the engine applies it inside
2688
+ # the security eval via register_security_eval's heikinashi flag — no
2689
+ # alternate symbol is loaded). All allow a current-symbol symbol
2690
+ # argument; a non-current one is genuine cross-symbol construction and
2691
+ # still falls through.
1763
2692
  if isinstance(node, FuncCall):
1764
2693
  ns, fname = _qualified_name(node.callee)
1765
2694
  if ns == "ticker" and fname in ("inherit", "standard", "heikinashi"):
1766
- if node.args and self._is_current_symbol_expr(node.args[0], _seen):
1767
- return True
1768
- if "symbol" in node.kwargs and self._is_current_symbol_expr(node.kwargs["symbol"], _seen):
2695
+ symbol = ticker_symbol_arg(node)
2696
+ if symbol is not None and self._is_current_symbol_expr(
2697
+ symbol, _seen, require_all_paths=require_all_paths,
2698
+ legacy_names=legacy_names,
2699
+ ):
1769
2700
  return True
1770
- # A ternary symbol (``cond ? other : syminfo.tickerid``) resolves to the
1771
- # chart symbol whenever EITHER branch does. PineForge only ever loads the
1772
- # chart symbol, so this is exact at any config where the chosen branch is
1773
- # the chart symbol; an unconditional cross-symbol expression still errors.
2701
+ # Preserve admission for a working script with an unreachable alternate
2702
+ # branch; the all-paths pass diagnoses a possibly wrong feed.
1774
2703
  if isinstance(node, Ternary):
1775
- return (self._is_current_symbol_expr(node.true_val, _seen)
1776
- or self._is_current_symbol_expr(node.false_val, _seen))
2704
+ true_safe = self._is_current_symbol_expr(
2705
+ node.true_val, _seen, require_all_paths=require_all_paths,
2706
+ legacy_names=legacy_names,
2707
+ )
2708
+ false_safe = self._is_current_symbol_expr(
2709
+ node.false_val, _seen, require_all_paths=require_all_paths,
2710
+ legacy_names=legacy_names,
2711
+ )
2712
+ return true_safe and false_safe if require_all_paths else true_safe or false_safe
1777
2713
  # Def-use: resolve a bare identifier through its declaration value so an
1778
2714
  # aliased symbol is accepted (``haTicker = ticker.heikinashi(...)`` then
1779
2715
  # ``request.security(haTicker, ...)``). Every ``:=`` rebind of the name
1780
2716
  # must resolve to the chart symbol too: the declaration alone does not
1781
- # pin the value the call actually receives. Name-cycle-guarded.
1782
- if isinstance(node, Identifier) and node.name not in _seen:
1783
- definition = self._scalar_defs.get(node.name)
1784
- rebinds = self._scalar_rebinds.get(node.name)
1785
- if definition is not None or rebinds:
1786
- _seen.add(node.name)
1787
- if rebinds and not all(
1788
- self._is_current_symbol_expr(value, _seen) for value in rebinds
1789
- ):
2717
+ # pin the value the call actually receives. Binding-cycle-guarded.
2718
+ if isinstance(node, Identifier):
2719
+ if legacy_names:
2720
+ binding = ("legacy", node.name)
2721
+ if binding in _seen:
1790
2722
  return False
2723
+ definition = self._scalar_defs.get(node.name)
1791
2724
  if definition is None:
1792
2725
  return False
1793
- return self._is_current_symbol_expr(definition, _seen)
2726
+ seen = _seen | {binding}
2727
+ return self._is_current_symbol_expr(
2728
+ definition, seen, require_all_paths=require_all_paths,
2729
+ legacy_names=True,
2730
+ ) and all(
2731
+ self._is_current_symbol_expr(
2732
+ value, seen, require_all_paths=require_all_paths,
2733
+ legacy_names=True,
2734
+ )
2735
+ for value in self._scalar_rebinds.get(node.name, ())
2736
+ )
2737
+ binding = self._security_symbol_refs.get(id(node))
2738
+ if (isinstance(binding, tuple) and binding[0] == "param"
2739
+ and binding not in _seen):
2740
+ return self._param_is_current_symbol(
2741
+ binding, _seen | {binding}, require_all_paths)
2742
+ if binding is not None and binding not in _seen:
2743
+ definition = self._security_symbol_defs.get(binding)
2744
+ if definition is None:
2745
+ return False
2746
+ seen = _seen | {binding}
2747
+ if not self._is_current_symbol_expr(
2748
+ definition, seen, require_all_paths=require_all_paths
2749
+ ):
2750
+ return False
2751
+ return all(
2752
+ self._is_current_symbol_expr(
2753
+ value, seen, require_all_paths=require_all_paths
2754
+ )
2755
+ for value in self._security_symbol_rebinds.get(binding, ())
2756
+ )
1794
2757
  return False
1795
2758
 
2759
+ def _param_is_current_symbol(
2760
+ self, binding: tuple, seen: frozenset, require_all_paths: bool,
2761
+ ) -> bool:
2762
+ """A helper parameter is the chart's symbol when the argument every
2763
+ call of the helper binds to it is, read in the caller's scope --
2764
+ through further helpers' parameters too. A helper nothing calls
2765
+ never runs. A method's parameter, or an overloaded helper's, keeps
2766
+ the refusal."""
2767
+ _, func_id, name = binding
2768
+ fdef = self._user_func_defs.get(func_id)
2769
+ if fdef is None or fdef.name in self._overloaded_user_funcs:
2770
+ return False
2771
+ index = fdef.params.index(name)
2772
+ defaults = (fdef.annotations or {}).get("param_defaults") or []
2773
+ for call in self._user_func_calls.get(fdef.name, ()):
2774
+ arg = call.kwargs.get(name)
2775
+ if arg is None and index < len(call.args):
2776
+ arg = call.args[index]
2777
+ if arg is None and index < len(defaults):
2778
+ arg = defaults[index]
2779
+ if arg is None or not self._is_current_symbol_expr(
2780
+ arg, seen, require_all_paths=require_all_paths):
2781
+ return False
2782
+ return True
2783
+
1796
2784
  # -- Pine timeframe-literal validation --
1797
2785
 
1798
2786
  @staticmethod
@@ -1844,6 +2832,39 @@ class SupportChecker:
1844
2832
  hint=err,
1845
2833
  )
1846
2834
 
2835
+ def _check_request_security_lower_tf_symbol(self, node: FuncCall) -> None:
2836
+ """``request.security_lower_tf`` reads the chart's intrabars: the
2837
+ engine loads no other symbol's. For another symbol whose value can
2838
+ reach a trade, evaluating the request stops the run with it named
2839
+ (``ABSENT_LOWERING``), never reading the chart's bars in its place;
2840
+ one reaching display and alert sinks only, or that can select the
2841
+ chart's symbol, keeps its lowering and warns."""
2842
+ symbol_node = node.args[0] if node.args else node.kwargs.get("symbol")
2843
+ if symbol_node is None or self._is_current_symbol_expr(
2844
+ symbol_node, require_all_paths=True):
2845
+ return
2846
+ if self._trade_slice is None:
2847
+ self._trade_slice = TradeSlice(self._ast)
2848
+ reason = self._trade_slice.reason(node)
2849
+ hint = "PineForge loads no other symbol's lower-timeframe bars."
2850
+ if (reason is not None and not self._is_current_symbol_expr(symbol_node)
2851
+ and not self._is_current_symbol_expr(symbol_node, legacy_names=True)):
2852
+ node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: ABSENT_LOWERING}
2853
+ self._warn(
2854
+ symbol_node,
2855
+ f"{spell_call(node)}: no data is pinned for this request; the run stops with "
2856
+ "an error where it is evaluated.",
2857
+ hint=f"{hint} Its value can reach a trade: {reason}.")
2858
+ return
2859
+ self._warn(
2860
+ symbol_node,
2861
+ f"{spell_call(node)}: its symbol can be another symbol's, whose lower-timeframe "
2862
+ "bars PineForge reads from the chart"
2863
+ + ("; its value reaches only display/alert sinks, so trades are unaffected."
2864
+ if reason is None else "."),
2865
+ hint=hint + " Every reachable symbol value must resolve to syminfo.tickerid or "
2866
+ "syminfo.ticker for exact results.")
2867
+
1847
2868
  def _check_request_security_lower_tf_tf(self, node: FuncCall) -> None:
1848
2869
  """Validate the ``timeframe`` argument literal for
1849
2870
  ``request.security_lower_tf``. All other parameter validation lives in