@pineforge/codegen-pyodide 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -576,39 +576,64 @@ class Parser:
576
576
 
577
577
  # -- Function definition --
578
578
 
579
+ def _parse_param_type_annotation(self) -> str | None:
580
+ """Consume an optional function-parameter type annotation and return it
581
+ as a canonical hint string ('float', 'string', 'array<line>', 'pivot',
582
+ 'chart.point', ...), or ``None`` for a bare untyped param.
583
+
584
+ Leaves the parser positioned at the parameter name. Supports the Pine
585
+ qualifier prefixes (``series`` / ``simple`` / ``const`` — consumed but
586
+ not part of the C++ type), builtin types, user-defined / drawing type
587
+ names, and the ``T[]`` postfix-array shorthand (normalised to
588
+ ``array<T>`` by ``_parse_type_hint_string``).
589
+ """
590
+ TYPE_TOKENS = {TokenType.TYPE_INT, TokenType.TYPE_FLOAT,
591
+ TokenType.TYPE_BOOL, TokenType.TYPE_STRING}
592
+ # Optional qualifiers — they do not affect the C++ param type.
593
+ while self._check(TokenType.IDENT) and self._current().value in (
594
+ "series", "simple", "const",
595
+ ):
596
+ self._advance()
597
+ # Is there a type annotation before the parameter name? A builtin type
598
+ # token always is; an IDENT is a type only if followed by another IDENT
599
+ # (``line ln``), by ``[`` (``line[] arr``), or by ``<`` (the generic
600
+ # collection syntax ``array<float> xs`` / ``matrix<float> m`` /
601
+ # ``map<string,float> mp``). Without the ``<`` case the generic type is
602
+ # mis-consumed as the parameter name and the whole function definition
603
+ # silently fails to parse (its body leaks to top-level scope).
604
+ has_type = False
605
+ if self._current().type in TYPE_TOKENS:
606
+ has_type = True
607
+ elif self._check(TokenType.IDENT):
608
+ nxt = self._peek().type
609
+ if nxt in (TokenType.IDENT, TokenType.LBRACKET, TokenType.LT):
610
+ has_type = True
611
+ if not has_type:
612
+ return None
613
+ return self._parse_type_hint_string()
614
+
579
615
  def _parse_func_def(self) -> FuncDef:
580
616
  """Parse: name(param1, param2) => expr_or_block"""
581
617
  start_tok = self._current()
582
618
  name = self._consume(TokenType.IDENT).value
583
619
  self._consume(TokenType.LPAREN)
584
- TYPE_TOKENS = {TokenType.TYPE_INT, TokenType.TYPE_FLOAT,
585
- TokenType.TYPE_BOOL, TokenType.TYPE_STRING}
586
620
  params = []
621
+ param_type_hints: list = []
622
+ param_defaults: list = []
587
623
  while not self._check(TokenType.RPAREN):
588
- # Consume optional Pine parameter type qualifiers, e.g. `series float x`,
589
- # `simple string maType`, `const int n`. Each is one qualifier in front
590
- # of the (optional) type and the name — NOT a separate parameter.
591
- while self._check(TokenType.IDENT) and self._current().value in (
592
- "series",
593
- "simple",
594
- "const",
595
- ):
596
- self._advance()
597
- # Handle optional type annotation: type param (e.g., int len, float src,
598
- # string s). Built-in types are dedicated tokens; user-defined types are
599
- # IDENTs and handled by the "next is IDENT" check below.
600
- if self._current().type in TYPE_TOKENS:
601
- self._advance() # skip the type annotation
624
+ # Consume the optional type annotation (builtin / user / drawing /
625
+ # ``T[]``), returning the canonical hint string. Handles ``float[] arr``,
626
+ # ``line[] ln``, ``color c``, ``SDZone z``, ``string tf``, as well as
627
+ # the untyped bare-name case.
628
+ hint = self._parse_param_type_annotation()
602
629
  param_name = self._consume(TokenType.IDENT).value
603
- if self._check(TokenType.IDENT):
604
- # 'param_name' was actually a (user-defined) type name parsed as IDENT,
605
- # next is the real name.
606
- param_name = self._consume(TokenType.IDENT).value
607
- # Skip default value: param = expr
630
+ pdefault = None
608
631
  if self._check(TokenType.EQUALS):
609
632
  self._advance() # consume '='
610
- self._parse_expression() # consume default value (discarded)
633
+ pdefault = self._parse_expression() # default value
611
634
  params.append(param_name)
635
+ param_type_hints.append(hint)
636
+ param_defaults.append(pdefault)
612
637
  self._match(TokenType.COMMA)
613
638
  self._consume(TokenType.RPAREN)
614
639
  self._skip_newlines()
@@ -625,6 +650,13 @@ class Parser:
625
650
  expr = self._parse_expression()
626
651
  node = FuncDef(name=name, params=params, body=[ExprStmt(expr=expr)], is_single_expr=True)
627
652
 
653
+ # Record per-param type hints + defaults (mirrors _parse_method_def) so
654
+ # the analyzer can type UDT/string/array params and the codegen can emit
655
+ # them with the correct C++ type (``pivot hi``, ``std::string s``).
656
+ node.annotations = {
657
+ "param_type_hints": param_type_hints,
658
+ "param_defaults": param_defaults,
659
+ }
628
660
  return self._set_loc(node, start_tok)
629
661
 
630
662
  def _parse_type_or_enum_decl(self):
@@ -701,7 +733,8 @@ class Parser:
701
733
  if self._current().type in TYPE_KEYWORDS:
702
734
  param_type = self._parse_type_hint_string()
703
735
  elif (self._current().type == TokenType.IDENT
704
- and self._peek().type == TokenType.IDENT):
736
+ and self._peek().type in (TokenType.IDENT, TokenType.LBRACKET, TokenType.LT)):
737
+ # ``line ln`` / ``float[] arr`` / ``array<float> xs`` typed param.
705
738
  param_type = self._parse_type_hint_string()
706
739
  p = self._consume(TokenType.IDENT).value
707
740
  pdefault = None
@@ -50,11 +50,15 @@ from .tv_input_choices import INPUT_SOURCE_SERIES_IDS
50
50
  from .analyzer import TA_CLASS_MAP
51
51
  from .codegen import (
52
52
  MATH_FUNC_MAP, STR_FUNC_MAP,
53
- ARRAY_METHODS, MAP_METHODS, MATRIX_METHODS,
53
+ ARRAY_METHODS, ARRAY_DRAWING_NEW_CTORS, MAP_METHODS, MATRIX_METHODS,
54
54
  SYMINFO_MEMBER_MAP, COLOR_CONST_MAP,
55
55
  SKIP_FUNC_NAMES, SKIP_NAMESPACES,
56
56
  BAR_BUILTINS, BAR_FIELDS,
57
57
  )
58
+ from .codegen.drawing import (
59
+ LINE_METHODS, LINE_NOOP, BOX_METHODS, BOX_NOOP,
60
+ LABEL_METHODS, LABEL_NOOP, LINEFILL_METHODS, LINEFILL_NOOP,
61
+ )
58
62
 
59
63
 
60
64
  # ---------------------------------------------------------------------------
@@ -74,10 +78,38 @@ SUPPORTED_MATH: frozenset[str] = frozenset(
74
78
  )
75
79
  SUPPORTED_STR: frozenset[str] = frozenset(set(STR_FUNC_MAP) | {"format_time"})
76
80
  SUPPORTED_INPUT: frozenset[str] = frozenset(sigs.INPUT_FUNCTIONS)
77
- SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"})
81
+ SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"} | set(ARRAY_DRAWING_NEW_CTORS))
78
82
  SUPPORTED_MAP: frozenset[str] = frozenset(set(MAP_METHODS) | {"new"})
79
83
  SUPPORTED_MATRIX: frozenset[str] = frozenset(set(MATRIX_METHODS) | {"new"})
80
84
  SUPPORTED_SYMINFO: frozenset[str] = frozenset(SYMINFO_MEMBER_MAP)
85
+ # Drawing-objects-as-data (spec §4.5). Geometry methods are REAL (route to the
86
+ # per-type arena); *_NOOP visual setters are accepted no-ops (Level.WARNING).
87
+ # Any line/box/label/linefill method NOT in these sets rejects loudly so an
88
+ # unknown drawing method is not silently emitted. ``table``/``polyline`` are
89
+ # intentionally absent — they stay in SKIP_NAMESPACES (no-op, never reclassified).
90
+ SUPPORTED_LINE: frozenset[str] = frozenset(LINE_METHODS | LINE_NOOP | {"new"})
91
+ SUPPORTED_BOX: frozenset[str] = frozenset(BOX_METHODS | BOX_NOOP | {"new"})
92
+ SUPPORTED_LABEL: frozenset[str] = frozenset(LABEL_METHODS | LABEL_NOOP | {"new"})
93
+ SUPPORTED_LINEFILL: frozenset[str] = frozenset(LINEFILL_METHODS | LINEFILL_NOOP | {"new"})
94
+ SUPPORTED_CHART_POINT: frozenset[str] = frozenset(
95
+ {"new", "now", "from_index", "from_time", "copy"}
96
+ )
97
+ # Visual no-op drawing setters (warn, don't reject) keyed by namespace.
98
+ _DRAWING_NOOP_BY_NS: dict[str, frozenset[str]] = {
99
+ "line": LINE_NOOP, "box": BOX_NOOP, "label": LABEL_NOOP, "linefill": LINEFILL_NOOP,
100
+ }
101
+ _DRAWING_SUPPORTED_BY_NS: dict[str, frozenset[str]] = {
102
+ "line": SUPPORTED_LINE, "box": SUPPORTED_BOX,
103
+ "label": SUPPORTED_LABEL, "linefill": SUPPORTED_LINEFILL,
104
+ }
105
+ _DRAWING_TYPE_NAMES: frozenset[str] = frozenset({"line", "box", "label", "linefill"})
106
+ # Scalar visual-container types whose *method* calls (``panel.cell(...)``,
107
+ # ``ln.set_xy1(...)``) legitimately carry visual-constant args (``text.align_*``,
108
+ # ``size.*``). A method on a receiver of one of these types — including a
109
+ # table/box/line/label/linefill PARAMETER — is a visual sink, so its argument
110
+ # subtree is visited with constant-namespace reads allowed (mirrors the
111
+ # namespace-form ``table.cell``/``label.new`` handling).
112
+ _VISUAL_CONTAINER_TYPES: frozenset[str] = _DRAWING_TYPE_NAMES | frozenset({"table"})
81
113
  SUPPORTED_COLOR_CONST: frozenset[str] = frozenset(COLOR_CONST_MAP)
82
114
  SUPPORTED_COLOR_FUNC: frozenset[str] = frozenset({"new", "rgb", "r", "g", "b", "t"})
83
115
  # Cosmetic color builders with no backtest-logic effect. Warned (not rejected);
@@ -100,11 +132,10 @@ HARD_REJECT_FUNC: dict[str, str] = {
100
132
  "request.seed": "External seed data feeds not available in PineForge.",
101
133
  "request.quandl": "External Quandl data not available in PineForge.",
102
134
  "request.currency_rate": "Currency conversion data not available in PineForge.",
103
- # ticker.* chart-type modifiers and cross-symbol constructors — hard reject
104
- "ticker.heikinashi": (
105
- "ticker.heikinashi() chart-type modifier / cross-symbol construction not supported — "
106
- "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
107
- ),
135
+ # ticker.* chart-type modifiers and cross-symbol constructors — hard reject.
136
+ # NOTE: ticker.heikinashi is handled contextually in _visit_FuncCall (allowed
137
+ # for the chart's own symbol — the engine applies the HA candle transform
138
+ # inside the same-symbol security eval — rejected for cross-symbol).
108
139
  "ticker.renko": (
109
140
  "ticker.renko() chart-type modifier / cross-symbol construction not supported — "
110
141
  "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
@@ -146,17 +177,23 @@ HARD_REJECT_NAMESPACE: dict[str, str] = {
146
177
  # DIVERGENT_VARS_ERROR is a SUBSET that is escalated to ERROR (rejected): these
147
178
  # are silent MIS-ALIASES, not merely data-window divergences. They return a
148
179
  # plausible value that is the WRONG quantity (last_bar_index -> current bar
149
- # index; time_close -> bar OPEN timestamp) and that value flows directly into
150
- # trade logic, so the backtest would be silently wrong. A WARNING is not enough.
180
+ # index) and that value flows directly into trade logic, so the backtest would
181
+ # be silently wrong. A WARNING is not enough.
182
+ #
183
+ # NOTE: the bare ``time_close`` variable is NOT divergent — codegen lowers it to
184
+ # the engine's ``time_close()`` accessor, which returns the true bar-close
185
+ # timestamp (bar open + chart-timeframe duration via ``pine_time_close``),
186
+ # deterministic and TV-faithful. It was previously mis-listed here as a bar-open
187
+ # alias; that was stale. (The session-aware ``time_close(...)`` FUNCTION is a
188
+ # separate supported builtin handled in visit_call.)
151
189
  DIVERGENT_VARS: dict[str, str] = {
152
190
  "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
153
191
  "last_bar_index": "last_bar_index is aliased to the CURRENT bar index in PineForge codegen (not the index of the last bar); backtest would be silently wrong — rejected.",
154
192
  "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.",
155
- "time_close": "time_close is aliased to the bar OPEN timestamp in PineForge; it does not represent the bar close time; backtest would be silently wrong — rejected.",
156
193
  }
157
194
 
158
195
  # Subset of DIVERGENT_VARS escalated from WARNING to ERROR (see comment above).
159
- DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index", "time_close"})
196
+ DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index"})
160
197
 
161
198
  BARSTATE_APPROX_VARS: dict[str, str] = {
162
199
  "barstate.islast": "barstate.islast is always false in PineForge batch backtests.",
@@ -411,6 +448,22 @@ class SupportChecker:
411
448
  self._user_enums: set[str] = set()
412
449
  self._user_funcs: set[str] = set()
413
450
  self._user_methods: set[str] = set()
451
+ # Drawing lifecycle gap guard (spec L.3/U.7): only bare identifiers are
452
+ # promoted to Series<T> by analyzer/base.py::_visit_Subscript. A history
453
+ # read on a drawing handle nested in a UDT field (``p.a[1]``) or on a
454
+ # tuple element known to be a drawing handle (``la[1]`` after
455
+ # ``[la, lb] = makePair()``) would otherwise silently read the current
456
+ # bar. Track enough lightweight declaration information to reject those
457
+ # shapes before codegen.
458
+ self._udt_drawing_fields: dict[str, set[str]] = {}
459
+ self._var_udt_types: dict[str, str] = {}
460
+ # Names (vars and function params) declared as a scalar visual-container
461
+ # type (table/box/line/label/linefill). A method call on one of these
462
+ # (``panel.cell(...)``) is a visual sink whose args may carry visual
463
+ # constants, so it routes through ``_visit_children_const_ok``.
464
+ self._visual_container_vars: set[str] = set()
465
+ self._drawing_tuple_vars: set[str] = set()
466
+ self._func_tuple_drawing_returns: dict[str, list[bool]] = {}
414
467
  # Track whether we are inside an if/ternary condition expression.
415
468
  self._in_conditional_depth: int = 0
416
469
  # Track whether we are inside an argument subtree that legitimately
@@ -425,6 +478,12 @@ class SupportChecker:
425
478
  # bare ``time_close`` variable) must NOT be flagged as a divergent
426
479
  # variable read. Populated as _visit_FuncCall descends into children.
427
480
  self._callee_node_ids: set[int] = set()
481
+ # Declaration value of each scalar variable (``name = <expr>``), so the
482
+ # request.security symbol check can resolve an aliased symbol through
483
+ # its definition — e.g. ``haTicker = ticker.heikinashi(syminfo.tickerid)``
484
+ # then ``request.security(haTicker, ...)``, or ``reqSym = cond ? other :
485
+ # syminfo.tickerid``. First binding wins (closest to declaration).
486
+ self._scalar_defs: dict[str, ASTNode] = {}
428
487
 
429
488
  # -- Public API --
430
489
 
@@ -446,12 +505,83 @@ class SupportChecker:
446
505
  for stmt in ast.body:
447
506
  if isinstance(stmt, TypeDecl):
448
507
  self._user_types.add(stmt.name)
508
+ drawing_fields = {
509
+ field.name
510
+ for field in stmt.fields
511
+ if self._type_name_contains_drawing(field.type_name)
512
+ }
513
+ if drawing_fields:
514
+ self._udt_drawing_fields[stmt.name] = drawing_fields
449
515
  elif isinstance(stmt, EnumDecl):
450
516
  self._user_enums.add(stmt.name)
451
517
  elif isinstance(stmt, FuncDef):
452
518
  self._user_funcs.add(stmt.name)
519
+ tuple_returns = self._single_expr_tuple_drawing_mask(stmt)
520
+ if tuple_returns:
521
+ self._func_tuple_drawing_returns[stmt.name] = tuple_returns
522
+ self._collect_visual_container_params(stmt)
453
523
  elif isinstance(stmt, MethodDef):
454
524
  self._user_methods.add(stmt.name)
525
+ self._collect_visual_container_params(stmt)
526
+
527
+ def _collect_visual_container_params(self, fn) -> None:
528
+ """Register a function's parameters that are declared with a scalar
529
+ visual-container type (``table panel``, ``line ln``) so method calls on
530
+ them inside the body (``panel.cell(...)``) are treated as visual sinks.
531
+ """
532
+ hints = (getattr(fn, "annotations", None) or {}).get("param_type_hints") or []
533
+ for i, pname in enumerate(getattr(fn, "params", []) or []):
534
+ hint = hints[i] if i < len(hints) else None
535
+ if hint and str(hint).replace(" ", "") in _VISUAL_CONTAINER_TYPES:
536
+ self._visual_container_vars.add(pname)
537
+
538
+ @staticmethod
539
+ def _type_name_contains_drawing(type_name: str | None) -> bool:
540
+ if not type_name:
541
+ return False
542
+ compact = str(type_name).replace(" ", "")
543
+ if compact.endswith("[]"):
544
+ compact = f"array<{compact[:-2]}>"
545
+ if compact in _DRAWING_TYPE_NAMES:
546
+ return True
547
+ return any(f"<{drawing}>" in compact for drawing in _DRAWING_TYPE_NAMES)
548
+
549
+ @staticmethod
550
+ def _is_literal_zero_subscript(node: Subscript) -> bool:
551
+ return isinstance(node.index, NumberLiteral) and node.index.value == 0
552
+
553
+ def _expr_is_drawing_handle(self, expr: ASTNode | None) -> bool:
554
+ if isinstance(expr, FuncCall):
555
+ ns, name = _qualified_name(expr.callee)
556
+ if ns in _DRAWING_TYPE_NAMES and name in {"new", "copy"}:
557
+ return True
558
+ if ns == "linefill" and name in {"get_line1", "get_line2"}:
559
+ return True
560
+ if isinstance(expr, Identifier):
561
+ return expr.name in self._drawing_tuple_vars
562
+ if isinstance(expr, Ternary):
563
+ return self._expr_is_drawing_handle(expr.true_val) or self._expr_is_drawing_handle(expr.false_val)
564
+ return False
565
+
566
+ def _single_expr_tuple_drawing_mask(self, fn: FuncDef) -> list[bool] | None:
567
+ if not fn.is_single_expr or len(fn.body) != 1:
568
+ return None
569
+ stmt = fn.body[0]
570
+ expr = stmt.expr if isinstance(stmt, ExprStmt) else None
571
+ if not isinstance(expr, TupleLiteral):
572
+ return None
573
+ mask = [self._expr_is_drawing_handle(item) for item in expr.elements]
574
+ return mask if any(mask) else None
575
+
576
+ def _tuple_assign_drawing_mask(self, value: ASTNode | None) -> list[bool] | None:
577
+ if isinstance(value, TupleLiteral):
578
+ mask = [self._expr_is_drawing_handle(item) for item in value.elements]
579
+ return mask if any(mask) else None
580
+ if isinstance(value, FuncCall):
581
+ ns, name = _qualified_name(value.callee)
582
+ if ns is None and name in self._func_tuple_drawing_returns:
583
+ return self._func_tuple_drawing_returns[name]
584
+ return None
455
585
 
456
586
  # -- Diagnostic emission --
457
587
 
@@ -639,6 +769,24 @@ class SupportChecker:
639
769
  self._visit_children_const_ok(node)
640
770
 
641
771
  def _visit_VarDecl(self, node: VarDecl) -> None:
772
+ if node.name and node.value is not None:
773
+ self._scalar_defs.setdefault(node.name, node.value)
774
+ if node.type_hint in self._user_types:
775
+ self._var_udt_types[node.name] = node.type_hint
776
+ elif isinstance(node.value, FuncCall):
777
+ ns, name = _qualified_name(node.value.callee)
778
+ if name == "new" and ns in self._user_types:
779
+ self._var_udt_types[node.name] = ns
780
+ # Track scalar visual-container vars (``var table dash = table.new(...)``)
781
+ # so a direct ``dash.cell(..., text.align_left)`` is treated as a visual
782
+ # sink (mirrors the table/box/line/label/linefill PARAM tracking).
783
+ decl_hint = str(node.type_hint).replace(" ", "") if node.type_hint else None
784
+ if decl_hint in _VISUAL_CONTAINER_TYPES:
785
+ self._visual_container_vars.add(node.name)
786
+ elif isinstance(node.value, FuncCall):
787
+ vns, vname = _qualified_name(node.value.callee)
788
+ if vname == "new" and vns in _VISUAL_CONTAINER_TYPES:
789
+ self._visual_container_vars.add(node.name)
642
790
  if node.is_varip:
643
791
  self._err(
644
792
  node,
@@ -655,6 +803,11 @@ class SupportChecker:
655
803
  self._visit_children(node)
656
804
 
657
805
  def _visit_TupleAssign(self, node: TupleAssign) -> None:
806
+ drawing_mask = self._tuple_assign_drawing_mask(node.value)
807
+ if drawing_mask:
808
+ for idx, name in enumerate(node.names):
809
+ if idx < len(drawing_mask) and drawing_mask[idx]:
810
+ self._drawing_tuple_vars.add(name)
658
811
  if isinstance(node.value, FuncCall):
659
812
  ns, name = _qualified_name(node.value.callee)
660
813
  if ns == "ta" and name == "stoch":
@@ -665,6 +818,40 @@ class SupportChecker:
665
818
  )
666
819
  self._visit_children(node)
667
820
 
821
+ def _visit_Subscript(self, node: Subscript) -> None:
822
+ # Best-effort defensive guard for the silent-wrong shapes called out in
823
+ # spec L.3/U.7. This is not a full expression type checker; it catches
824
+ # the known dangerous surface (UDT vars with drawing fields, and tuple
825
+ # destructured drawing handles) without trying to prove every nested or
826
+ # parameter-passed UDT expression. Literal [0] is current-bar in Pine and
827
+ # safe to leave to codegen's existing current-value behavior.
828
+ if self._is_literal_zero_subscript(node):
829
+ self._visit_children(node)
830
+ return
831
+ if isinstance(node.object, MemberAccess) and isinstance(node.object.object, Identifier):
832
+ var_name = node.object.object.name
833
+ udt_name = self._var_udt_types.get(var_name)
834
+ if udt_name is not None and node.object.member in self._udt_drawing_fields.get(udt_name, set()):
835
+ self._err(
836
+ node,
837
+ "History references on UDT drawing fields are not supported in PineForge.",
838
+ hint=(
839
+ f"{var_name}.{node.object.member}[...] would be read as the current-bar field, "
840
+ "not prior-bar history. Copy the field to a bare drawing variable first, "
841
+ "or remove the history reference."
842
+ ),
843
+ )
844
+ elif isinstance(node.object, Identifier) and node.object.name in self._drawing_tuple_vars:
845
+ self._err(
846
+ node,
847
+ "History references on tuple-destructured drawing handles are not supported in PineForge.",
848
+ hint=(
849
+ f"{node.object.name}[...] is a drawing handle from tuple destructuring; "
850
+ "PineForge cannot historize that tuple element safely."
851
+ ),
852
+ )
853
+ self._visit_children(node)
854
+
668
855
  def _visit_FuncCall(self, node: FuncCall) -> None:
669
856
  ns, name = _qualified_name(node.callee)
670
857
 
@@ -686,6 +873,25 @@ class SupportChecker:
686
873
  self._visit_children(node)
687
874
  return
688
875
 
876
+ # Heikin-Ashi: supported for the chart's OWN symbol only. The engine
877
+ # applies the HA candle transform inside the same-symbol security eval
878
+ # (request.security(ticker.heikinashi(syminfo.tickerid), ...)); a
879
+ # cross-symbol HA construction (an alternate symbol's candles) is not.
880
+ if full == "ticker.heikinashi":
881
+ arg0 = node.args[0] if node.args else node.kwargs.get("symbol")
882
+ if arg0 is None or not self._is_current_symbol_expr(arg0):
883
+ self._err(
884
+ node,
885
+ "ticker.heikinashi(...) is supported only for the chart's own symbol.",
886
+ hint=(
887
+ "Pass syminfo.tickerid: PineForge applies the Heikin-Ashi candle "
888
+ "transform to the chart symbol inside request.security, but cannot "
889
+ "load an alternate symbol's HA candles."
890
+ ),
891
+ )
892
+ self._visit_children(node)
893
+ return
894
+
689
895
  # Hard rejects by namespace (e.g. ticker.*).
690
896
  if ns is not None and ns in HARD_REJECT_NAMESPACE:
691
897
  self._err(
@@ -942,6 +1148,34 @@ class SupportChecker:
942
1148
  self._visit_children(node)
943
1149
  return
944
1150
 
1151
+ # Drawing-objects-as-data (line/box/label/linefill). Geometry methods
1152
+ # are REAL data; *_NOOP visual setters are accepted no-ops (warned);
1153
+ # any OTHER drawing method rejects loudly. Args carry constant-namespace
1154
+ # members (xloc.*, yloc.*, text.align_*, style consts), so children are
1155
+ # visited with const reads allowed. NOTE: user methods on a drawing var
1156
+ # (egoigor ``ln.slope()``) have ns="ln" (the receiver), NOT a DRAWING_NS,
1157
+ # so they are never gated here.
1158
+ if ns in _DRAWING_SUPPORTED_BY_NS:
1159
+ if name not in _DRAWING_SUPPORTED_BY_NS[ns]:
1160
+ self._err(node, f"{ns}.{name}(...) is not implemented in PineForge runtime.")
1161
+ self._visit_children(node)
1162
+ return
1163
+ if name in _DRAWING_NOOP_BY_NS[ns]:
1164
+ self._warn(
1165
+ node,
1166
+ f"{ns}.{name}(...) is a visual no-op in PineForge backtests "
1167
+ f"(drawing geometry is data; color/style/size are dropped).",
1168
+ )
1169
+ self._visit_children_const_ok(node)
1170
+ return
1171
+ if ns == "chart.point":
1172
+ if name not in SUPPORTED_CHART_POINT:
1173
+ self._err(node, f"chart.point.{name}(...) is not implemented in PineForge runtime.")
1174
+ self._visit_children(node)
1175
+ return
1176
+ self._visit_children_const_ok(node)
1177
+ return
1178
+
945
1179
  # Drawing / charting / alert namespaces — codegen drops silently. Warn,
946
1180
  # don't error: many strategies include these for the TradingView UI.
947
1181
  # Their argument subtrees legitimately carry constant-namespace
@@ -962,6 +1196,17 @@ class SupportChecker:
962
1196
  self._visit_children_const_ok(node)
963
1197
  return
964
1198
 
1199
+ # Method call on a scalar visual-container receiver (``panel.cell(...)``
1200
+ # where ``panel`` is a ``table`` param, ``ln.set_xy1(...)`` on a ``line``
1201
+ # var). ``ns`` is the receiver variable/param name, not a namespace, so
1202
+ # the namespace-form drawing/table branches above did not fire. These are
1203
+ # visual sinks whose args legitimately carry visual constants
1204
+ # (``text.align_*``, ``size.*``), so visit children with const reads
1205
+ # allowed instead of tripping the free-expression const-namespace reject.
1206
+ if ns is not None and ns in self._visual_container_vars:
1207
+ self._visit_children_const_ok(node)
1208
+ return
1209
+
965
1210
  self._visit_children(node)
966
1211
 
967
1212
  def _visit_Identifier(self, node: Identifier) -> None:
@@ -1238,19 +1483,38 @@ class SupportChecker:
1238
1483
  return False
1239
1484
  return node.member in allowed
1240
1485
 
1241
- def _is_current_symbol_expr(self, node: ASTNode) -> bool:
1486
+ def _is_current_symbol_expr(self, node: ASTNode, _seen: set[str] | None = None) -> bool:
1487
+ if _seen is None:
1488
+ _seen = set()
1242
1489
  chain = _resolve_member_chain(node)
1243
1490
  if chain in SECURITY_CURRENT_SYMBOL_NAMES:
1244
1491
  return True
1245
- # ticker.inherit(symbol, ...) and ticker.standard(symbol) are passthrough;
1246
- # if their first argument is a current-symbol expression, allow it.
1492
+ # ticker.inherit(symbol, ...) / ticker.standard(symbol) are passthrough,
1493
+ # and ticker.heikinashi(symbol) is the chart's OWN symbol with a causal
1494
+ # Heikin-Ashi candle transform (the engine applies it inside the security
1495
+ # eval via register_security_eval's heikinashi flag — no alternate symbol
1496
+ # is loaded). All allow a current-symbol first argument; a non-current
1497
+ # arg is genuine cross-symbol construction and still falls through.
1247
1498
  if isinstance(node, FuncCall):
1248
1499
  ns, fname = _qualified_name(node.callee)
1249
- if ns == "ticker" and fname in ("inherit", "standard"):
1250
- if node.args and self._is_current_symbol_expr(node.args[0]):
1500
+ if ns == "ticker" and fname in ("inherit", "standard", "heikinashi"):
1501
+ if node.args and self._is_current_symbol_expr(node.args[0], _seen):
1251
1502
  return True
1252
- if "symbol" in node.kwargs and self._is_current_symbol_expr(node.kwargs["symbol"]):
1503
+ if "symbol" in node.kwargs and self._is_current_symbol_expr(node.kwargs["symbol"], _seen):
1253
1504
  return True
1505
+ # A ternary symbol (``cond ? other : syminfo.tickerid``) resolves to the
1506
+ # chart symbol whenever EITHER branch does. PineForge only ever loads the
1507
+ # chart symbol, so this is exact at any config where the chosen branch is
1508
+ # the chart symbol; an unconditional cross-symbol expression still errors.
1509
+ if isinstance(node, Ternary):
1510
+ return (self._is_current_symbol_expr(node.true_val, _seen)
1511
+ or self._is_current_symbol_expr(node.false_val, _seen))
1512
+ # Def-use: resolve a bare identifier through its declaration value so an
1513
+ # aliased symbol is accepted (``haTicker = ticker.heikinashi(...)`` then
1514
+ # ``request.security(haTicker, ...)``). Name-cycle-guarded.
1515
+ if isinstance(node, Identifier) and node.name in self._scalar_defs and node.name not in _seen:
1516
+ _seen.add(node.name)
1517
+ return self._is_current_symbol_expr(self._scalar_defs[node.name], _seen)
1254
1518
  return False
1255
1519
 
1256
1520
  # -- Pine timeframe-literal validation --
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "0.8.0",
2
+ "codegen": "0.8.1",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "6b81a8d07ce4daa940178d204dadad410fc7952c83ab43e92abf5d8e869c2a31"
6
+ "sha256": "85ac44383967dd8c9825a276661eab568b16ecb89fcb929a3b9b638b6a4f371c"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "0.8.0",
2
+ "CODEGEN_VERSION": "0.8.1",
3
3
  "TA_CLASS_MAP_KEYS": [
4
4
  "sma",
5
5
  "ema",
@@ -321,12 +321,8 @@
321
321
  "plotshape"
322
322
  ],
323
323
  "SKIP_NAMESPACES": [
324
- "box",
325
324
  "chart",
326
325
  "display",
327
- "label",
328
- "line",
329
- "linefill",
330
326
  "polyline",
331
327
  "position",
332
328
  "size",
@@ -1438,7 +1434,6 @@
1438
1434
  "request.seed": "External seed data feeds not available in PineForge.",
1439
1435
  "request.quandl": "External Quandl data not available in PineForge.",
1440
1436
  "request.currency_rate": "Currency conversion data not available in PineForge.",
1441
- "ticker.heikinashi": "ticker.heikinashi() chart-type modifier / cross-symbol construction not supported — PineForge runs same-symbol MTF only via request.security with the chart's own tickerid.",
1442
1437
  "ticker.renko": "ticker.renko() chart-type modifier / cross-symbol construction not supported — PineForge runs same-symbol MTF only via request.security with the chart's own tickerid.",
1443
1438
  "ticker.kagi": "ticker.kagi() chart-type modifier / cross-symbol construction not supported — PineForge runs same-symbol MTF only via request.security with the chart's own tickerid.",
1444
1439
  "ticker.linebreak": "ticker.linebreak() chart-type modifier / cross-symbol construction not supported — PineForge runs same-symbol MTF only via request.security with the chart's own tickerid.",
@@ -1453,8 +1448,7 @@
1453
1448
  "DIVERGENT_VARS": {
1454
1449
  "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
1455
1450
  "last_bar_index": "last_bar_index is aliased to the CURRENT bar index in PineForge codegen (not the index of the last bar); backtest would be silently wrong — rejected.",
1456
- "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.",
1457
- "time_close": "time_close is aliased to the bar OPEN timestamp in PineForge; it does not represent the bar close time; backtest would be silently wrong — rejected."
1451
+ "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time."
1458
1452
  },
1459
1453
  "BARSTATE_APPROX_VARS": {
1460
1454
  "barstate.islast": "barstate.islast is always false in PineForge batch backtests.",
Binary file