@pineforge/codegen-pyodide 0.8.0 → 0.9.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.
@@ -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.",
@@ -305,10 +342,14 @@ UNSUPPORTED_NAMESPACE_VARS: dict[str, str] = {
305
342
  # request.security parameter rules.
306
343
  # Codegen supports symbol/timeframe/expression plus gaps/lookahead (read in
307
344
  # _eval_security_* emission and forwarded to register_security_eval).
308
- # ignore_invalid_symbol/currency are accepted by the parser but silently
309
- # dropped by codegen — reject loudly to surface unsupported behavior.
345
+ # `currency` is still rejected loudly (it changes the returned values via FX
346
+ # conversion, which codegen drops — silently wrong). `ignore_invalid_symbol` is
347
+ # a guaranteed no-op here: codegen forces request.security onto the current
348
+ # chart symbol (see the "symbol must reference current chart symbol" check),
349
+ # which is always valid, so the flag can never change the result — accept+ignore.
310
350
  SECURITY_ALLOWED_PARAMS: frozenset[str] = frozenset(
311
351
  {"symbol", "timeframe", "expression", "gaps", "lookahead",
352
+ "ignore_invalid_symbol",
312
353
  # Data-adjustment constants — silently accepted and ignored by codegen;
313
354
  # the underlying engine uses a fixed unadjusted data source.
314
355
  "backadjustment", "settlement_as_close", "adjustment"}
@@ -411,6 +452,28 @@ class SupportChecker:
411
452
  self._user_enums: set[str] = set()
412
453
  self._user_funcs: set[str] = set()
413
454
  self._user_methods: set[str] = set()
455
+ # Drawing lifecycle gap guard (spec L.3/U.7): only bare identifiers are
456
+ # promoted to Series<T> by analyzer/base.py::_visit_Subscript. A history
457
+ # read on a drawing handle nested in a UDT field (``p.a[1]``) or on a
458
+ # tuple element known to be a drawing handle (``la[1]`` after
459
+ # ``[la, lb] = makePair()``) would otherwise silently read the current
460
+ # bar. Track enough lightweight declaration information to reject those
461
+ # shapes before codegen.
462
+ self._udt_drawing_fields: dict[str, set[str]] = {}
463
+ self._var_udt_types: dict[str, str] = {}
464
+ # Names (vars and function params) declared as a scalar visual-container
465
+ # type (table/box/line/label/linefill). A method call on one of these
466
+ # (``panel.cell(...)``) is a visual sink whose args may carry visual
467
+ # constants, so it routes through ``_visit_children_const_ok``.
468
+ self._visual_container_vars: set[str] = set()
469
+ # User helpers whose body is only a visual sink (for example
470
+ # ``cell(table t, ..., align) => t.cell(..., text_halign=align)``).
471
+ # A call to such a helper is itself a visual context, so style
472
+ # constants passed through its arguments are safe and should not trip
473
+ # the free-expression constant-namespace rejection.
474
+ self._visual_sink_funcs: set[str] = set()
475
+ self._drawing_tuple_vars: set[str] = set()
476
+ self._func_tuple_drawing_returns: dict[str, list[bool]] = {}
414
477
  # Track whether we are inside an if/ternary condition expression.
415
478
  self._in_conditional_depth: int = 0
416
479
  # Track whether we are inside an argument subtree that legitimately
@@ -425,6 +488,12 @@ class SupportChecker:
425
488
  # bare ``time_close`` variable) must NOT be flagged as a divergent
426
489
  # variable read. Populated as _visit_FuncCall descends into children.
427
490
  self._callee_node_ids: set[int] = set()
491
+ # Declaration value of each scalar variable (``name = <expr>``), so the
492
+ # request.security symbol check can resolve an aliased symbol through
493
+ # its definition — e.g. ``haTicker = ticker.heikinashi(syminfo.tickerid)``
494
+ # then ``request.security(haTicker, ...)``, or ``reqSym = cond ? other :
495
+ # syminfo.tickerid``. First binding wins (closest to declaration).
496
+ self._scalar_defs: dict[str, ASTNode] = {}
428
497
 
429
498
  # -- Public API --
430
499
 
@@ -446,12 +515,106 @@ class SupportChecker:
446
515
  for stmt in ast.body:
447
516
  if isinstance(stmt, TypeDecl):
448
517
  self._user_types.add(stmt.name)
518
+ drawing_fields = {
519
+ field.name
520
+ for field in stmt.fields
521
+ if self._type_name_contains_drawing(field.type_name)
522
+ }
523
+ if drawing_fields:
524
+ self._udt_drawing_fields[stmt.name] = drawing_fields
449
525
  elif isinstance(stmt, EnumDecl):
450
526
  self._user_enums.add(stmt.name)
451
527
  elif isinstance(stmt, FuncDef):
452
528
  self._user_funcs.add(stmt.name)
529
+ tuple_returns = self._single_expr_tuple_drawing_mask(stmt)
530
+ if tuple_returns:
531
+ self._func_tuple_drawing_returns[stmt.name] = tuple_returns
532
+ self._collect_visual_container_params(stmt)
533
+ if self._func_body_is_visual_sink(stmt):
534
+ self._visual_sink_funcs.add(stmt.name)
453
535
  elif isinstance(stmt, MethodDef):
454
536
  self._user_methods.add(stmt.name)
537
+ self._collect_visual_container_params(stmt)
538
+
539
+ def _collect_visual_container_params(self, fn) -> None:
540
+ """Register a function's parameters that are declared with a scalar
541
+ visual-container type (``table panel``, ``line ln``) so method calls on
542
+ them inside the body (``panel.cell(...)``) are treated as visual sinks.
543
+ """
544
+ hints = (getattr(fn, "annotations", None) or {}).get("param_type_hints") or []
545
+ for i, pname in enumerate(getattr(fn, "params", []) or []):
546
+ hint = hints[i] if i < len(hints) else None
547
+ if hint and str(hint).replace(" ", "") in _VISUAL_CONTAINER_TYPES:
548
+ self._visual_container_vars.add(pname)
549
+
550
+ def _expr_is_visual_sink_call(self, expr: ASTNode | None) -> bool:
551
+ if not isinstance(expr, FuncCall):
552
+ return False
553
+ ns, name = _qualified_name(expr.callee)
554
+ if ns is None and name in SKIP_FUNC_NAMES:
555
+ return True
556
+ if ns is not None and ns in SKIP_NAMESPACES:
557
+ return True
558
+ if ns in _DRAWING_NOOP_BY_NS and name in _DRAWING_NOOP_BY_NS[ns]:
559
+ return True
560
+ return ns is not None and ns in self._visual_container_vars
561
+
562
+ def _func_body_is_visual_sink(self, fn: FuncDef | MethodDef) -> bool:
563
+ if not fn.body:
564
+ return False
565
+ for stmt in fn.body:
566
+ expr = stmt.expr if isinstance(stmt, ExprStmt) else None
567
+ if not self._expr_is_visual_sink_call(expr):
568
+ return False
569
+ return True
570
+
571
+ @staticmethod
572
+ def _type_name_contains_drawing(type_name: str | None) -> bool:
573
+ if not type_name:
574
+ return False
575
+ compact = str(type_name).replace(" ", "")
576
+ if compact.endswith("[]"):
577
+ compact = f"array<{compact[:-2]}>"
578
+ if compact in _DRAWING_TYPE_NAMES:
579
+ return True
580
+ return any(f"<{drawing}>" in compact for drawing in _DRAWING_TYPE_NAMES)
581
+
582
+ @staticmethod
583
+ def _is_literal_zero_subscript(node: Subscript) -> bool:
584
+ return isinstance(node.index, NumberLiteral) and node.index.value == 0
585
+
586
+ def _expr_is_drawing_handle(self, expr: ASTNode | None) -> bool:
587
+ if isinstance(expr, FuncCall):
588
+ ns, name = _qualified_name(expr.callee)
589
+ if ns in _DRAWING_TYPE_NAMES and name in {"new", "copy"}:
590
+ return True
591
+ if ns == "linefill" and name in {"get_line1", "get_line2"}:
592
+ return True
593
+ if isinstance(expr, Identifier):
594
+ return expr.name in self._drawing_tuple_vars
595
+ if isinstance(expr, Ternary):
596
+ return self._expr_is_drawing_handle(expr.true_val) or self._expr_is_drawing_handle(expr.false_val)
597
+ return False
598
+
599
+ def _single_expr_tuple_drawing_mask(self, fn: FuncDef) -> list[bool] | None:
600
+ if not fn.is_single_expr or len(fn.body) != 1:
601
+ return None
602
+ stmt = fn.body[0]
603
+ expr = stmt.expr if isinstance(stmt, ExprStmt) else None
604
+ if not isinstance(expr, TupleLiteral):
605
+ return None
606
+ mask = [self._expr_is_drawing_handle(item) for item in expr.elements]
607
+ return mask if any(mask) else None
608
+
609
+ def _tuple_assign_drawing_mask(self, value: ASTNode | None) -> list[bool] | None:
610
+ if isinstance(value, TupleLiteral):
611
+ mask = [self._expr_is_drawing_handle(item) for item in value.elements]
612
+ return mask if any(mask) else None
613
+ if isinstance(value, FuncCall):
614
+ ns, name = _qualified_name(value.callee)
615
+ if ns is None and name in self._func_tuple_drawing_returns:
616
+ return self._func_tuple_drawing_returns[name]
617
+ return None
455
618
 
456
619
  # -- Diagnostic emission --
457
620
 
@@ -639,6 +802,24 @@ class SupportChecker:
639
802
  self._visit_children_const_ok(node)
640
803
 
641
804
  def _visit_VarDecl(self, node: VarDecl) -> None:
805
+ if node.name and node.value is not None:
806
+ self._scalar_defs.setdefault(node.name, node.value)
807
+ if node.type_hint in self._user_types:
808
+ self._var_udt_types[node.name] = node.type_hint
809
+ elif isinstance(node.value, FuncCall):
810
+ ns, name = _qualified_name(node.value.callee)
811
+ if name == "new" and ns in self._user_types:
812
+ self._var_udt_types[node.name] = ns
813
+ # Track scalar visual-container vars (``var table dash = table.new(...)``)
814
+ # so a direct ``dash.cell(..., text.align_left)`` is treated as a visual
815
+ # sink (mirrors the table/box/line/label/linefill PARAM tracking).
816
+ decl_hint = str(node.type_hint).replace(" ", "") if node.type_hint else None
817
+ if decl_hint in _VISUAL_CONTAINER_TYPES:
818
+ self._visual_container_vars.add(node.name)
819
+ elif isinstance(node.value, FuncCall):
820
+ vns, vname = _qualified_name(node.value.callee)
821
+ if vname == "new" and vns in _VISUAL_CONTAINER_TYPES:
822
+ self._visual_container_vars.add(node.name)
642
823
  if node.is_varip:
643
824
  self._err(
644
825
  node,
@@ -655,6 +836,11 @@ class SupportChecker:
655
836
  self._visit_children(node)
656
837
 
657
838
  def _visit_TupleAssign(self, node: TupleAssign) -> None:
839
+ drawing_mask = self._tuple_assign_drawing_mask(node.value)
840
+ if drawing_mask:
841
+ for idx, name in enumerate(node.names):
842
+ if idx < len(drawing_mask) and drawing_mask[idx]:
843
+ self._drawing_tuple_vars.add(name)
658
844
  if isinstance(node.value, FuncCall):
659
845
  ns, name = _qualified_name(node.value.callee)
660
846
  if ns == "ta" and name == "stoch":
@@ -665,6 +851,40 @@ class SupportChecker:
665
851
  )
666
852
  self._visit_children(node)
667
853
 
854
+ def _visit_Subscript(self, node: Subscript) -> None:
855
+ # Best-effort defensive guard for the silent-wrong shapes called out in
856
+ # spec L.3/U.7. This is not a full expression type checker; it catches
857
+ # the known dangerous surface (UDT vars with drawing fields, and tuple
858
+ # destructured drawing handles) without trying to prove every nested or
859
+ # parameter-passed UDT expression. Literal [0] is current-bar in Pine and
860
+ # safe to leave to codegen's existing current-value behavior.
861
+ if self._is_literal_zero_subscript(node):
862
+ self._visit_children(node)
863
+ return
864
+ if isinstance(node.object, MemberAccess) and isinstance(node.object.object, Identifier):
865
+ var_name = node.object.object.name
866
+ udt_name = self._var_udt_types.get(var_name)
867
+ if udt_name is not None and node.object.member in self._udt_drawing_fields.get(udt_name, set()):
868
+ self._err(
869
+ node,
870
+ "History references on UDT drawing fields are not supported in PineForge.",
871
+ hint=(
872
+ f"{var_name}.{node.object.member}[...] would be read as the current-bar field, "
873
+ "not prior-bar history. Copy the field to a bare drawing variable first, "
874
+ "or remove the history reference."
875
+ ),
876
+ )
877
+ elif isinstance(node.object, Identifier) and node.object.name in self._drawing_tuple_vars:
878
+ self._err(
879
+ node,
880
+ "History references on tuple-destructured drawing handles are not supported in PineForge.",
881
+ hint=(
882
+ f"{node.object.name}[...] is a drawing handle from tuple destructuring; "
883
+ "PineForge cannot historize that tuple element safely."
884
+ ),
885
+ )
886
+ self._visit_children(node)
887
+
668
888
  def _visit_FuncCall(self, node: FuncCall) -> None:
669
889
  ns, name = _qualified_name(node.callee)
670
890
 
@@ -686,6 +906,25 @@ class SupportChecker:
686
906
  self._visit_children(node)
687
907
  return
688
908
 
909
+ # Heikin-Ashi: supported for the chart's OWN symbol only. The engine
910
+ # applies the HA candle transform inside the same-symbol security eval
911
+ # (request.security(ticker.heikinashi(syminfo.tickerid), ...)); a
912
+ # cross-symbol HA construction (an alternate symbol's candles) is not.
913
+ if full == "ticker.heikinashi":
914
+ arg0 = node.args[0] if node.args else node.kwargs.get("symbol")
915
+ if arg0 is None or not self._is_current_symbol_expr(arg0):
916
+ self._err(
917
+ node,
918
+ "ticker.heikinashi(...) is supported only for the chart's own symbol.",
919
+ hint=(
920
+ "Pass syminfo.tickerid: PineForge applies the Heikin-Ashi candle "
921
+ "transform to the chart symbol inside request.security, but cannot "
922
+ "load an alternate symbol's HA candles."
923
+ ),
924
+ )
925
+ self._visit_children(node)
926
+ return
927
+
689
928
  # Hard rejects by namespace (e.g. ticker.*).
690
929
  if ns is not None and ns in HARD_REJECT_NAMESPACE:
691
930
  self._err(
@@ -942,6 +1181,34 @@ class SupportChecker:
942
1181
  self._visit_children(node)
943
1182
  return
944
1183
 
1184
+ # Drawing-objects-as-data (line/box/label/linefill). Geometry methods
1185
+ # are REAL data; *_NOOP visual setters are accepted no-ops (warned);
1186
+ # any OTHER drawing method rejects loudly. Args carry constant-namespace
1187
+ # members (xloc.*, yloc.*, text.align_*, style consts), so children are
1188
+ # visited with const reads allowed. NOTE: user methods on a drawing var
1189
+ # (egoigor ``ln.slope()``) have ns="ln" (the receiver), NOT a DRAWING_NS,
1190
+ # so they are never gated here.
1191
+ if ns in _DRAWING_SUPPORTED_BY_NS:
1192
+ if name not in _DRAWING_SUPPORTED_BY_NS[ns]:
1193
+ self._err(node, f"{ns}.{name}(...) is not implemented in PineForge runtime.")
1194
+ self._visit_children(node)
1195
+ return
1196
+ if name in _DRAWING_NOOP_BY_NS[ns]:
1197
+ self._warn(
1198
+ node,
1199
+ f"{ns}.{name}(...) is a visual no-op in PineForge backtests "
1200
+ f"(drawing geometry is data; color/style/size are dropped).",
1201
+ )
1202
+ self._visit_children_const_ok(node)
1203
+ return
1204
+ if ns == "chart.point":
1205
+ if name not in SUPPORTED_CHART_POINT:
1206
+ self._err(node, f"chart.point.{name}(...) is not implemented in PineForge runtime.")
1207
+ self._visit_children(node)
1208
+ return
1209
+ self._visit_children_const_ok(node)
1210
+ return
1211
+
945
1212
  # Drawing / charting / alert namespaces — codegen drops silently. Warn,
946
1213
  # don't error: many strategies include these for the TradingView UI.
947
1214
  # Their argument subtrees legitimately carry constant-namespace
@@ -962,6 +1229,21 @@ class SupportChecker:
962
1229
  self._visit_children_const_ok(node)
963
1230
  return
964
1231
 
1232
+ # Method call on a scalar visual-container receiver (``panel.cell(...)``
1233
+ # where ``panel`` is a ``table`` param, ``ln.set_xy1(...)`` on a ``line``
1234
+ # var). ``ns`` is the receiver variable/param name, not a namespace, so
1235
+ # the namespace-form drawing/table branches above did not fire. These are
1236
+ # visual sinks whose args legitimately carry visual constants
1237
+ # (``text.align_*``, ``size.*``), so visit children with const reads
1238
+ # allowed instead of tripping the free-expression const-namespace reject.
1239
+ if ns is not None and ns in self._visual_container_vars:
1240
+ self._visit_children_const_ok(node)
1241
+ return
1242
+
1243
+ if ns is None and name in self._visual_sink_funcs:
1244
+ self._visit_children_const_ok(node)
1245
+ return
1246
+
965
1247
  self._visit_children(node)
966
1248
 
967
1249
  def _visit_Identifier(self, node: Identifier) -> None:
@@ -1238,19 +1520,38 @@ class SupportChecker:
1238
1520
  return False
1239
1521
  return node.member in allowed
1240
1522
 
1241
- def _is_current_symbol_expr(self, node: ASTNode) -> bool:
1523
+ def _is_current_symbol_expr(self, node: ASTNode, _seen: set[str] | None = None) -> bool:
1524
+ if _seen is None:
1525
+ _seen = set()
1242
1526
  chain = _resolve_member_chain(node)
1243
1527
  if chain in SECURITY_CURRENT_SYMBOL_NAMES:
1244
1528
  return True
1245
- # ticker.inherit(symbol, ...) and ticker.standard(symbol) are passthrough;
1246
- # if their first argument is a current-symbol expression, allow it.
1529
+ # ticker.inherit(symbol, ...) / ticker.standard(symbol) are passthrough,
1530
+ # and ticker.heikinashi(symbol) is the chart's OWN symbol with a causal
1531
+ # Heikin-Ashi candle transform (the engine applies it inside the security
1532
+ # eval via register_security_eval's heikinashi flag — no alternate symbol
1533
+ # is loaded). All allow a current-symbol first argument; a non-current
1534
+ # arg is genuine cross-symbol construction and still falls through.
1247
1535
  if isinstance(node, FuncCall):
1248
1536
  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]):
1537
+ if ns == "ticker" and fname in ("inherit", "standard", "heikinashi"):
1538
+ if node.args and self._is_current_symbol_expr(node.args[0], _seen):
1251
1539
  return True
1252
- if "symbol" in node.kwargs and self._is_current_symbol_expr(node.kwargs["symbol"]):
1540
+ if "symbol" in node.kwargs and self._is_current_symbol_expr(node.kwargs["symbol"], _seen):
1253
1541
  return True
1542
+ # A ternary symbol (``cond ? other : syminfo.tickerid``) resolves to the
1543
+ # chart symbol whenever EITHER branch does. PineForge only ever loads the
1544
+ # chart symbol, so this is exact at any config where the chosen branch is
1545
+ # the chart symbol; an unconditional cross-symbol expression still errors.
1546
+ if isinstance(node, Ternary):
1547
+ return (self._is_current_symbol_expr(node.true_val, _seen)
1548
+ or self._is_current_symbol_expr(node.false_val, _seen))
1549
+ # Def-use: resolve a bare identifier through its declaration value so an
1550
+ # aliased symbol is accepted (``haTicker = ticker.heikinashi(...)`` then
1551
+ # ``request.security(haTicker, ...)``). Name-cycle-guarded.
1552
+ if isinstance(node, Identifier) and node.name in self._scalar_defs and node.name not in _seen:
1553
+ _seen.add(node.name)
1554
+ return self._is_current_symbol_expr(self._scalar_defs[node.name], _seen)
1254
1555
  return False
1255
1556
 
1256
1557
  # -- Pine timeframe-literal validation --
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "0.8.0",
2
+ "codegen": "0.9.0",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "6b81a8d07ce4daa940178d204dadad410fc7952c83ab43e92abf5d8e869c2a31"
6
+ "sha256": "fc828c2a32ba93bbeeffa8e0a4c4696a417a6cc64448c1b70bdce2267cd051d9"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "0.8.0",
2
+ "CODEGEN_VERSION": "0.9.0",
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",
@@ -581,11 +577,13 @@
581
577
  ],
582
578
  "ta.stdev": [
583
579
  "source",
584
- "length"
580
+ "length",
581
+ "biased"
585
582
  ],
586
583
  "ta.variance": [
587
584
  "source",
588
- "length"
585
+ "length",
586
+ "biased"
589
587
  ],
590
588
  "ta.supertrend": [
591
589
  "factor",
@@ -1438,7 +1436,6 @@
1438
1436
  "request.seed": "External seed data feeds not available in PineForge.",
1439
1437
  "request.quandl": "External Quandl data not available in PineForge.",
1440
1438
  "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
1439
  "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
1440
  "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
1441
  "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 +1450,7 @@
1453
1450
  "DIVERGENT_VARS": {
1454
1451
  "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
1455
1452
  "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."
1453
+ "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time."
1458
1454
  },
1459
1455
  "BARSTATE_APPROX_VARS": {
1460
1456
  "barstate.islast": "barstate.islast is always false in PineForge batch backtests.",
@@ -1580,6 +1576,7 @@
1580
1576
  "backadjustment",
1581
1577
  "expression",
1582
1578
  "gaps",
1579
+ "ignore_invalid_symbol",
1583
1580
  "lookahead",
1584
1581
  "settlement_as_close",
1585
1582
  "symbol",
Binary file