@pineforge/codegen-pyodide 0.7.6 → 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.
@@ -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,12 +78,44 @@ 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"})
115
+ # 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.
118
+ COSMETIC_COLOR_FUNC: frozenset[str] = frozenset({"from_gradient"})
83
119
  SUPPORTED_TIMEFRAME_FUNC: frozenset[str] = frozenset({"change", "in_seconds"})
84
120
  SUPPORTED_RUNTIME_FUNC: frozenset[str] = frozenset({"error"})
85
121
  # log.* helpers wired into pine_log_{info,warning,error} by codegen/visit_call.
@@ -96,12 +132,10 @@ HARD_REJECT_FUNC: dict[str, str] = {
96
132
  "request.seed": "External seed data feeds not available in PineForge.",
97
133
  "request.quandl": "External Quandl data not available in PineForge.",
98
134
  "request.currency_rate": "Currency conversion data not available in PineForge.",
99
- "color.from_gradient": "Charting helpers not available in PineForge backtests.",
100
- # ticker.* chart-type modifiers and cross-symbol constructors — hard reject
101
- "ticker.heikinashi": (
102
- "ticker.heikinashi() chart-type modifier / cross-symbol construction not supported — "
103
- "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
104
- ),
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).
105
139
  "ticker.renko": (
106
140
  "ticker.renko() chart-type modifier / cross-symbol construction not supported — "
107
141
  "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
@@ -143,17 +177,23 @@ HARD_REJECT_NAMESPACE: dict[str, str] = {
143
177
  # DIVERGENT_VARS_ERROR is a SUBSET that is escalated to ERROR (rejected): these
144
178
  # are silent MIS-ALIASES, not merely data-window divergences. They return a
145
179
  # plausible value that is the WRONG quantity (last_bar_index -> current bar
146
- # index; time_close -> bar OPEN timestamp) and that value flows directly into
147
- # 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.)
148
189
  DIVERGENT_VARS: dict[str, str] = {
149
190
  "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
150
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.",
151
192
  "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.",
152
- "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.",
153
193
  }
154
194
 
155
195
  # Subset of DIVERGENT_VARS escalated from WARNING to ERROR (see comment above).
156
- DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index", "time_close"})
196
+ DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index"})
157
197
 
158
198
  BARSTATE_APPROX_VARS: dict[str, str] = {
159
199
  "barstate.islast": "barstate.islast is always false in PineForge batch backtests.",
@@ -215,10 +255,17 @@ NOT_YET_FUNC: dict[str, str] = {
215
255
  }
216
256
 
217
257
  # Bare (no-namespace) function names that codegen has no handler for.
218
- # Without a handler, the generic emitter at visit_call.py:912 would
219
- # produce e.g. `color(arg)` — an undeclared C++ symbol. Reject loudly.
220
- UNSUPPORTED_BARE_FUNCS: dict[str, str] = {
221
- "color": "Bare color(...) cast is not supported. Use color.new(c, alpha) or color.rgb(r, g, b, transp).",
258
+ # Without a handler, the generic emitter at visit_call.py:912 would produce an
259
+ # undeclared C++ symbol. Reject loudly. (Currently empty — the bare color(...)
260
+ # cast moved to COSMETIC_BARE_FUNCS below.)
261
+ UNSUPPORTED_BARE_FUNCS: dict[str, str] = {}
262
+
263
+ # Bare (no-namespace) cosmetic casts with no backtest-logic effect. Warned
264
+ # (not rejected); codegen emits a benign default. ``color(x)`` is a Pine color
265
+ # cast — colors never affect trade logic, so codegen emits a default color
266
+ # (0 = na color) and the strategy keeps running.
267
+ COSMETIC_BARE_FUNCS: dict[str, str] = {
268
+ "color": "color(...) cast has no effect in PineForge backtests (visual only); it emits a default color.",
222
269
  }
223
270
 
224
271
  # Whole namespaces with NO codegen support. Any call into one of these
@@ -230,14 +277,19 @@ UNSUPPORTED_NAMESPACES: dict[str, str] = {
230
277
  "volume_row": "volume_row.* is not supported in PineForge batch backtests; same reason as footprint.*.",
231
278
  }
232
279
 
233
- # Member-access references with no batch-mode equivalent. Codegen would
234
- # silently emit "false" (visit_expr.py chart.* fallthrough) which
235
- # becomes epoch 0 in time arithmetic. Reject loudly.
236
- UNSUPPORTED_MEMBERS: dict[tuple[str, str], str] = {
237
- ("chart", "left_visible_bar_time"): "chart.left_visible_bar_time has no meaning in a batch backtest (no viewport).",
238
- ("chart", "right_visible_bar_time"): "chart.right_visible_bar_time has no meaning in a batch backtest (no viewport).",
239
- ("chart", "bg_color"): "chart.bg_color has no meaning in a batch backtest (no chart theme).",
240
- ("chart", "fg_color"): "chart.fg_color has no meaning in a batch backtest (no chart theme).",
280
+ # Member-access references with no batch-mode equivalent. Reject loudly.
281
+ # (Currently empty — the chart.* cosmetic reads moved to COSMETIC_MEMBERS.)
282
+ UNSUPPORTED_MEMBERS: dict[tuple[str, str], str] = {}
283
+
284
+ # Cosmetic / chart-only member reads with no backtest-logic effect. Warned
285
+ # (not rejected); codegen emits the benign default — chart.* falls through
286
+ # SKIP_NAMESPACES in visit_expr.py to ``0`` (and the analyzer types chart.* as
287
+ # COLOR, so the 0 lands in an int color slot and compiles cleanly).
288
+ COSMETIC_MEMBERS: dict[tuple[str, str], str] = {
289
+ ("chart", "left_visible_bar_time"): "chart.left_visible_bar_time has no meaning in a batch backtest (no viewport); emits 0.",
290
+ ("chart", "right_visible_bar_time"): "chart.right_visible_bar_time has no meaning in a batch backtest (no viewport); emits 0.",
291
+ ("chart", "bg_color"): "chart.bg_color has no meaning in a batch backtest (no chart theme); emits a default color.",
292
+ ("chart", "fg_color"): "chart.fg_color has no meaning in a batch backtest (no chart theme); emits a default color.",
241
293
  }
242
294
 
243
295
  # Constant-only namespaces whose members are drawing/visual style constants
@@ -396,6 +448,22 @@ class SupportChecker:
396
448
  self._user_enums: set[str] = set()
397
449
  self._user_funcs: set[str] = set()
398
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]] = {}
399
467
  # Track whether we are inside an if/ternary condition expression.
400
468
  self._in_conditional_depth: int = 0
401
469
  # Track whether we are inside an argument subtree that legitimately
@@ -410,6 +478,12 @@ class SupportChecker:
410
478
  # bare ``time_close`` variable) must NOT be flagged as a divergent
411
479
  # variable read. Populated as _visit_FuncCall descends into children.
412
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] = {}
413
487
 
414
488
  # -- Public API --
415
489
 
@@ -431,12 +505,83 @@ class SupportChecker:
431
505
  for stmt in ast.body:
432
506
  if isinstance(stmt, TypeDecl):
433
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
434
515
  elif isinstance(stmt, EnumDecl):
435
516
  self._user_enums.add(stmt.name)
436
517
  elif isinstance(stmt, FuncDef):
437
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)
438
523
  elif isinstance(stmt, MethodDef):
439
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
440
585
 
441
586
  # -- Diagnostic emission --
442
587
 
@@ -624,6 +769,24 @@ class SupportChecker:
624
769
  self._visit_children_const_ok(node)
625
770
 
626
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)
627
790
  if node.is_varip:
628
791
  self._err(
629
792
  node,
@@ -640,6 +803,11 @@ class SupportChecker:
640
803
  self._visit_children(node)
641
804
 
642
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)
643
811
  if isinstance(node.value, FuncCall):
644
812
  ns, name = _qualified_name(node.value.callee)
645
813
  if ns == "ta" and name == "stoch":
@@ -650,6 +818,40 @@ class SupportChecker:
650
818
  )
651
819
  self._visit_children(node)
652
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
+
653
855
  def _visit_FuncCall(self, node: FuncCall) -> None:
654
856
  ns, name = _qualified_name(node.callee)
655
857
 
@@ -671,6 +873,25 @@ class SupportChecker:
671
873
  self._visit_children(node)
672
874
  return
673
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
+
674
895
  # Hard rejects by namespace (e.g. ticker.*).
675
896
  if ns is not None and ns in HARD_REJECT_NAMESPACE:
676
897
  self._err(
@@ -692,9 +913,15 @@ class SupportChecker:
692
913
  self._visit_children(node)
693
914
  return
694
915
 
695
- # Bare-function rejections (e.g. `color(arg)` cast). Codegen would
696
- # otherwise fall through to the generic emit at visit_call.py:912 and
697
- # produce an undeclared C++ symbol.
916
+ # Bare cosmetic casts (e.g. `color(na)` -> default color). No backtest
917
+ # effect; warn and let codegen emit a benign default color.
918
+ if ns is None and name in COSMETIC_BARE_FUNCS:
919
+ self._warn(node, COSMETIC_BARE_FUNCS[name])
920
+ self._visit_children(node)
921
+ return
922
+
923
+ # Bare-function rejections. Codegen would otherwise fall through to the
924
+ # generic emit at visit_call.py:912 and produce an undeclared C++ symbol.
698
925
  if ns is None and self._reject_if_in(
699
926
  UNSUPPORTED_BARE_FUNCS,
700
927
  name,
@@ -865,6 +1092,16 @@ class SupportChecker:
865
1092
  self._err(node, f"timeframe.{name}(...) is not implemented in PineForge runtime.")
866
1093
  self._visit_children(node)
867
1094
  return
1095
+ if ns == "color" and name in COSMETIC_COLOR_FUNC:
1096
+ # Cosmetic color builder (e.g. color.from_gradient): no backtest
1097
+ # effect. Warn and let codegen emit a default color (0).
1098
+ self._warn(
1099
+ node,
1100
+ f"color.{name}(...) has no effect in PineForge backtests "
1101
+ f"(visual only); it emits a default color.",
1102
+ )
1103
+ self._visit_children(node)
1104
+ return
868
1105
  if ns == "color" and name not in SUPPORTED_COLOR_FUNC:
869
1106
  self._err(node, f"color.{name}(...) is not implemented in PineForge runtime.")
870
1107
  self._visit_children(node)
@@ -911,6 +1148,34 @@ class SupportChecker:
911
1148
  self._visit_children(node)
912
1149
  return
913
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
+
914
1179
  # Drawing / charting / alert namespaces — codegen drops silently. Warn,
915
1180
  # don't error: many strategies include these for the TradingView UI.
916
1181
  # Their argument subtrees legitimately carry constant-namespace
@@ -931,6 +1196,17 @@ class SupportChecker:
931
1196
  self._visit_children_const_ok(node)
932
1197
  return
933
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
+
934
1210
  self._visit_children(node)
935
1211
 
936
1212
  def _visit_Identifier(self, node: Identifier) -> None:
@@ -1001,7 +1277,21 @@ class SupportChecker:
1001
1277
  lambda k, v: f"{k}.{node.member}: {v}",
1002
1278
  ):
1003
1279
  return
1004
- # Specific unsupported (namespace, member) pairs (e.g. chart.left_visible_bar_time).
1280
+ # Cosmetic / chart-only member reads (chart.fg_color/bg_color/visible
1281
+ # bar times). No backtest-logic effect; warn and let codegen emit the
1282
+ # benign default (chart.* -> 0 via SKIP_NAMESPACES in visit_expr.py).
1283
+ if (
1284
+ isinstance(node.object, Identifier)
1285
+ and (node.object.name, node.member) in COSMETIC_MEMBERS
1286
+ ):
1287
+ self._warn(
1288
+ node,
1289
+ f"{node.object.name}.{node.member}: "
1290
+ f"{COSMETIC_MEMBERS[(node.object.name, node.member)]}",
1291
+ )
1292
+ self._visit_children(node)
1293
+ return
1294
+ # Specific unsupported (namespace, member) pairs.
1005
1295
  if isinstance(node.object, Identifier) and self._reject_if_in(
1006
1296
  UNSUPPORTED_MEMBERS,
1007
1297
  (node.object.name, node.member),
@@ -1134,10 +1424,25 @@ class SupportChecker:
1134
1424
  hint="Codegen only recognizes the barmerge.lookahead_* literal; other values are silently treated as lookahead_off.",
1135
1425
  )
1136
1426
  if self._is_barmerge_member(lookahead_node, "lookahead_on"):
1137
- self._err(
1427
+ # lookahead_on is supported by codegen + engine: base.py forwards
1428
+ # the flag into _security_eval_info, emit_top.py registers it via
1429
+ # register_security_eval(..., lookahead_on=true), and the engine
1430
+ # (engine_security.cpp) dispatches the partial HTF eval and gates
1431
+ # the per-bucket series slot on it. The completed-HTF value becomes
1432
+ # visible from the HTF bucket's first chart bar (TV's forward-look).
1433
+ # We emit a WARNING (not ERROR) because lookahead_on is inherently
1434
+ # data-sensitive: with a 0-offset HTF expression it exposes the
1435
+ # in-progress bucket's value (TV's documented repaint), whereas the
1436
+ # safe non-repainting idiom pairs it with a [1] offset
1437
+ # (e.g. close[1]) to read only completed prior buckets.
1438
+ self._warn(
1138
1439
  lookahead_node,
1139
- "request.security lookahead_on is not supported in PineForge paid parity mode.",
1140
- hint="Use barmerge.lookahead_off. lookahead_on can expose future/partial HTF values and is highly data-sensitive.",
1440
+ "request.security lookahead_on changes HTF timing: the completed "
1441
+ "higher-timeframe value is exposed from the bucket's first chart "
1442
+ "bar. PineForge emits this (engine-supported) but results differ "
1443
+ "from lookahead_off and, with a 0-offset HTF expression, repaint "
1444
+ "future data.",
1445
+ hint="The safe non-repainting form pairs lookahead_on with a [1] offset (e.g. close[1]) to read only completed prior HTF bars.",
1141
1446
  )
1142
1447
 
1143
1448
  # Data-adjustment kwargs: codegen emits a numeric constant but the
@@ -1178,19 +1483,38 @@ class SupportChecker:
1178
1483
  return False
1179
1484
  return node.member in allowed
1180
1485
 
1181
- 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()
1182
1489
  chain = _resolve_member_chain(node)
1183
1490
  if chain in SECURITY_CURRENT_SYMBOL_NAMES:
1184
1491
  return True
1185
- # ticker.inherit(symbol, ...) and ticker.standard(symbol) are passthrough;
1186
- # 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.
1187
1498
  if isinstance(node, FuncCall):
1188
1499
  ns, fname = _qualified_name(node.callee)
1189
- if ns == "ticker" and fname in ("inherit", "standard"):
1190
- 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):
1191
1502
  return True
1192
- 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):
1193
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)
1194
1518
  return False
1195
1519
 
1196
1520
  # -- Pine timeframe-literal validation --
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "0.7.6",
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": "5f4c3778294be55b3b234f63d0c9f8e24b3d7a2b20ff83efa5034a5c5369e492"
6
+ "sha256": "85ac44383967dd8c9825a276661eab568b16ecb89fcb929a3b9b638b6a4f371c"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "0.7.6",
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,8 +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
- "color.from_gradient": "Charting helpers not available in PineForge backtests.",
1442
- "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.",
1443
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.",
1444
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.",
1445
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.",
@@ -1454,8 +1448,7 @@
1454
1448
  "DIVERGENT_VARS": {
1455
1449
  "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
1456
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.",
1457
- "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.",
1458
- "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."
1459
1452
  },
1460
1453
  "BARSTATE_APPROX_VARS": {
1461
1454
  "barstate.islast": "barstate.islast is always false in PineForge batch backtests.",
@@ -1541,9 +1534,7 @@
1541
1534
  "trail_points",
1542
1535
  "trail_price"
1543
1536
  ],
1544
- "UNSUPPORTED_BARE_FUNCS": {
1545
- "color": "Bare color(...) cast is not supported. Use color.new(c, alpha) or color.rgb(r, g, b, transp)."
1546
- },
1537
+ "UNSUPPORTED_BARE_FUNCS": {},
1547
1538
  "UNSUPPORTED_NAMESPACES": {
1548
1539
  "footprint": "footprint.* (bid/ask volume rows) is not supported in PineForge batch backtests; it requires tick-level data the engine does not consume.",
1549
1540
  "volume_row": "volume_row.* is not supported in PineForge batch backtests; same reason as footprint.*."
@@ -1562,12 +1553,7 @@
1562
1553
  "barmerge": "is only valid as the gaps/lookahead argument of request.security(...); it has no runtime value as a free expression in PineForge.",
1563
1554
  "alert": "is only valid as the freq argument of alert(...); it has no runtime value as a free expression in PineForge."
1564
1555
  },
1565
- "UNSUPPORTED_MEMBERS": {
1566
- "chart.left_visible_bar_time": "chart.left_visible_bar_time has no meaning in a batch backtest (no viewport).",
1567
- "chart.right_visible_bar_time": "chart.right_visible_bar_time has no meaning in a batch backtest (no viewport).",
1568
- "chart.bg_color": "chart.bg_color has no meaning in a batch backtest (no chart theme).",
1569
- "chart.fg_color": "chart.fg_color has no meaning in a batch backtest (no chart theme)."
1570
- },
1556
+ "UNSUPPORTED_MEMBERS": {},
1571
1557
  "UNSUPPORTED_NAMESPACE_VARS": {
1572
1558
  "dividends": "dividends.* is not available in PineForge — fundamental dividend data is not loaded.",
1573
1559
  "earnings": "earnings.* is not available in PineForge — fundamental earnings data is not loaded.",
Binary file