@pineforge/codegen-pyodide 0.10.4 → 1.0.0-rc.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.
Files changed (50) hide show
  1. package/README.md +16 -16
  2. package/glue.py +24 -16
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +125 -34
  5. package/pineforge_codegen/analyzer/__init__.py +2 -0
  6. package/pineforge_codegen/analyzer/base.py +754 -76
  7. package/pineforge_codegen/analyzer/call_handlers.py +260 -40
  8. package/pineforge_codegen/analyzer/contracts.py +37 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +30 -4
  10. package/pineforge_codegen/analyzer/tables.py +49 -8
  11. package/pineforge_codegen/analyzer/types.py +33 -1
  12. package/pineforge_codegen/ast_nodes.py +32 -1
  13. package/pineforge_codegen/block_locals.py +185 -0
  14. package/pineforge_codegen/builtin_keywords.py +42 -0
  15. package/pineforge_codegen/codegen/base.py +896 -156
  16. package/pineforge_codegen/codegen/constant_fold.py +131 -0
  17. package/pineforge_codegen/codegen/drawing.py +221 -79
  18. package/pineforge_codegen/codegen/emit_top.py +946 -213
  19. package/pineforge_codegen/codegen/helpers.py +435 -14
  20. package/pineforge_codegen/codegen/host_members.py +162 -0
  21. package/pineforge_codegen/codegen/input.py +252 -85
  22. package/pineforge_codegen/codegen/security.py +4372 -377
  23. package/pineforge_codegen/codegen/session_market.py +71 -0
  24. package/pineforge_codegen/codegen/ta.py +1188 -100
  25. package/pineforge_codegen/codegen/tables.py +193 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +1882 -78
  28. package/pineforge_codegen/codegen/visit_call.py +920 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +738 -57
  30. package/pineforge_codegen/codegen/visit_stmt.py +595 -49
  31. package/pineforge_codegen/external_requests.py +877 -0
  32. package/pineforge_codegen/lexer.py +104 -22
  33. package/pineforge_codegen/library_inline.py +1304 -0
  34. package/pineforge_codegen/library_modules.py +126 -0
  35. package/pineforge_codegen/library_v5.py +683 -0
  36. package/pineforge_codegen/limits.py +138 -0
  37. package/pineforge_codegen/method_binding.py +33 -0
  38. package/pineforge_codegen/parser.py +384 -68
  39. package/pineforge_codegen/pine_libraries.py +266 -0
  40. package/pineforge_codegen/pine_spelling.py +216 -0
  41. package/pineforge_codegen/pragmas.py +64 -10
  42. package/pineforge_codegen/security_contexts.py +1585 -0
  43. package/pineforge_codegen/session_reads.py +84 -0
  44. package/pineforge_codegen/signatures.py +48 -23
  45. package/pineforge_codegen/support_checker.py +1106 -85
  46. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  47. package/release.json +2 -2
  48. package/tables.json +23 -21
  49. package/transpile.worker.mjs +24 -16
  50. package/pineforge_codegen-0.10.4.tar.gz +0 -0
@@ -87,8 +87,11 @@ classes from ``..ast_nodes``.
87
87
 
88
88
  from __future__ import annotations
89
89
 
90
+ from ..errors import Phase
91
+ from ..external_requests import UNPINNED_ANNOTATION
90
92
  from ..ast_nodes import (
91
93
  ASTNode,
94
+ Assignment,
92
95
  BinOp,
93
96
  BoolLiteral,
94
97
  ColorLiteral,
@@ -103,8 +106,11 @@ from ..ast_nodes import (
103
106
  TupleLiteral,
104
107
  UnaryOp,
105
108
  )
109
+ from .helpers import pine_index_int_cast, unary_sign_cpp
110
+ from .types import COLLECTION_MUTATING_METHODS
106
111
  from .tables import (
107
112
  ADJUSTMENT_MAP,
113
+ ALERT_FREQ_VALUES,
108
114
  BAR_BUILTINS,
109
115
  BAR_FIELDS,
110
116
  BAR_SERIES_PUSH,
@@ -180,6 +186,50 @@ _RELATIONAL_OPS: frozenset[str] = frozenset({"==", "!=", "<", ">", "<=", ">="})
180
186
  # na-bool is engine-indistinguishable from ``false`` (na<bool>() == false).
181
187
  _NA_SCALAR_CPP: frozenset[str] = frozenset({"int", "int64_t", "double"})
182
188
 
189
+ # What a binary operand no effect of the other operand can change is built
190
+ # from (``_binop_operand_is_order_free``): literals and variables, these
191
+ # calls, and operators over them.
192
+ _ORDER_FREE_LITERALS = (
193
+ NumberLiteral, StringLiteral, BoolLiteral, NaLiteral, ColorLiteral,
194
+ )
195
+ _ORDER_FREE_CALL_NAMESPACES = frozenset({"str", "math", "color", "ta"})
196
+ _ORDER_FREE_CALLS = frozenset({"nz", "na", "int", "float", "bool", "string"})
197
+ # Builtin calls with an effect whose order is observable
198
+ # (``_expr_has_ordered_effect``): an order, a log line, a script stop.
199
+ _ORDERED_EFFECT_CALLS: frozenset[tuple[str, str]] = frozenset({
200
+ ("strategy", "entry"), ("strategy", "order"), ("strategy", "exit"),
201
+ ("strategy", "close"), ("strategy", "close_all"),
202
+ ("strategy", "cancel"), ("strategy", "cancel_all"),
203
+ ("log", "info"), ("log", "warning"), ("log", "error"),
204
+ ("runtime", "error"),
205
+ })
206
+ # Every call of these namespaces but a ``get_*`` getter creates, copies,
207
+ # changes or deletes a drawing; the method forms are ``set_*``,
208
+ # ``cell_set_*`` and these.
209
+ _DRAWING_EFFECT_NAMESPACES = frozenset({
210
+ "label", "line", "box", "table", "polyline", "linefill",
211
+ })
212
+ _DRAWING_EFFECT_METHODS = frozenset({"delete", "cell", "merge_cells"})
213
+
214
+
215
+ def _ast_children(node):
216
+ """The AST nodes directly under ``node``: its fields, lists, tuples (a
217
+ ``switch`` case) and dicts (keyword arguments), not its annotations."""
218
+ for name, value in vars(node).items():
219
+ if name not in ("loc", "annotations"):
220
+ yield from _ast_nodes_in(value)
221
+
222
+
223
+ def _ast_nodes_in(value):
224
+ if isinstance(value, ASTNode):
225
+ yield value
226
+ elif isinstance(value, (list, tuple)):
227
+ for item in value:
228
+ yield from _ast_nodes_in(item)
229
+ elif isinstance(value, dict):
230
+ for item in value.values():
231
+ yield from _ast_nodes_in(item)
232
+
183
233
 
184
234
  class ExprVisitor:
185
235
  """Expression-level visitor methods shared across the codegen.
@@ -194,6 +244,16 @@ class ExprVisitor:
194
244
  def _visit_expr(self, node: ASTNode | None) -> str:
195
245
  if node is None:
196
246
  return "/* null */"
247
+ if self._budget is not None:
248
+ self._budget_visit_count += 1
249
+ if self._budget_visit_count % 128 == 0:
250
+ self._budget.check(node.loc, Phase.CODEGEN)
251
+ if node.annotations and UNPINNED_ANNOTATION in node.annotations:
252
+ return self._unpinned_read(node)
253
+ if self._security_fallback_frame is not None:
254
+ delegated = self._security_fallback_delegate(node)
255
+ if delegated is not None:
256
+ return delegated
197
257
  if isinstance(node, NumberLiteral):
198
258
  return str(node.value)
199
259
  if isinstance(node, StringLiteral):
@@ -213,10 +273,7 @@ class ExprVisitor:
213
273
  if isinstance(node, UnaryOp):
214
274
  return self._visit_unaryop(node)
215
275
  if isinstance(node, Ternary):
216
- c = self._visit_expr(node.condition)
217
- t = self._visit_expr(node.true_val)
218
- f = self._visit_expr(node.false_val)
219
- return f"(({c}) ? ({t}) : ({f}))"
276
+ return self._visit_ternary(node)
220
277
  if isinstance(node, FuncCall):
221
278
  return self._visit_func_call(node)
222
279
  if isinstance(node, Subscript):
@@ -240,9 +297,55 @@ class ExprVisitor:
240
297
  return f"std::make_tuple({elems})"
241
298
  return "/* unknown */"
242
299
 
300
+ # A ``?:`` chain continued in its false arms (``a ? x : b ? y : z``) of at
301
+ # least this many links is emitted without the per-link brackets: they add
302
+ # three bracket levels per link and clang stops at 256
303
+ # (``-fbracket-depth``). C++'s conditional operator is right-associative,
304
+ # so ``a ? x : b ? y : z`` reads ``a ? x : (b ? y : z)``. Shorter chains
305
+ # keep their historical spelling byte for byte.
306
+ _FLAT_TERNARY_CHAIN = 32
307
+
308
+ def _visit_ternary(self, node: Ternary) -> str:
309
+ links = [node]
310
+ while isinstance(links[-1].false_val, Ternary):
311
+ links.append(links[-1].false_val)
312
+ string_na = self._ternary_string_na(links)
313
+ if len(links) < self._FLAT_TERNARY_CHAIN:
314
+ c = self._coerce_bool_expr(
315
+ self._visit_expr(node.condition), node.condition
316
+ )
317
+ t = self._ternary_arm(node.true_val, string_na)
318
+ f = self._ternary_arm(node.false_val, string_na)
319
+ return f"(({c}) ? ({t}) : ({f}))"
320
+ arms = []
321
+ for link in links:
322
+ c = self._coerce_bool_expr(
323
+ self._visit_expr(link.condition), link.condition
324
+ )
325
+ t = self._ternary_arm(link.true_val, string_na)
326
+ arms.append(f"({c}) ? ({t}) : ")
327
+ return f"({''.join(arms)}({self._ternary_arm(links[-1].false_val, string_na)}))"
328
+
329
+ def _ternary_string_na(self, links: list[Ternary]) -> bool:
330
+ """A ``?:`` chain whose every value arm but a bare ``na`` is a
331
+ string: its ``na`` arm is the string na (``cond ? "BUY" : na``, as
332
+ libraries return a signal). It used to emit ``na<double>()`` beside a
333
+ ``std::string``, which does not compile."""
334
+ arms = [link.true_val for link in links] + [links[-1].false_val]
335
+ values = [arm for arm in arms if not self._is_na_expr(arm)]
336
+ if len(values) == len(arms) or not values:
337
+ return False
338
+ return all(self._infer_type(arm) == "std::string" for arm in values)
339
+
340
+ def _ternary_arm(self, arm, string_na: bool) -> str:
341
+ if string_na and self._is_na_expr(arm):
342
+ return "na<std::string>()"
343
+ return self._visit_expr(arm)
344
+
243
345
  # ------------------------------------------------------------------
244
346
  # Target-typed RHS lowering (drawing handles are C++ structs, not doubles)
245
347
  # ------------------------------------------------------------------
348
+
246
349
  def _is_na_expr(self, node) -> bool:
247
350
  """True for a bare ``na`` (keyword NaLiteral or ``na`` identifier)."""
248
351
  return (isinstance(node, NaLiteral)
@@ -341,7 +444,7 @@ class ExprVisitor:
341
444
  if drawing_target is not None:
342
445
  return f"{drawing_target}{{}}"
343
446
  if target_cpp_type in self._udt_defs:
344
- return f"{target_cpp_type}{{}}"
447
+ return f"{self._safe_name(target_cpp_type)}{{}}"
345
448
  if self._is_nullable_collection_cpp_type(target_cpp_type):
346
449
  # Maps and matrices use default construction for a typed
347
450
  # ``na`` ID. Their ``*.new`` factories create a valid ID,
@@ -376,6 +479,30 @@ class ExprVisitor:
376
479
  )
377
480
  return self._visit_expr(value_node)
378
481
 
482
+ def _unpinned_read(self, node: ASTNode) -> str:
483
+ """A read of a request no data is pinned for
484
+ (``external_requests``): the run stops with the request named, and
485
+ the value the expression would have is only there for its type. A
486
+ request lowered onto pinned data stops the run so only when its data
487
+ is missing (``_request_data_missing``)."""
488
+ notes = node.annotations
489
+ marker = notes[UNPINNED_ANNOTATION]
490
+ node.annotations = {k: v for k, v in notes.items() if k != UNPINNED_ANNOTATION}
491
+ try:
492
+ value = self._visit_expr(node)
493
+ finally:
494
+ node.annotations = notes
495
+ if isinstance(marker, dict):
496
+ # Evaluated first: a recorded request sets its flag where it is
497
+ # evaluated, which may be this read.
498
+ stop = (f'pine_runtime_error(std::string('
499
+ f'"{self._cpp_string_escape(marker["message"])}"))')
500
+ return (f"([&]() {{ auto _pf_read = {value}; "
501
+ f"if ({self._request_data_missing(marker['ref'])}) {stop}; "
502
+ f"return _pf_read; }}())")
503
+ return (f'([&]() {{ pine_runtime_error(std::string("{self._cpp_string_escape(marker)}")); '
504
+ f"return {value}; }}())")
505
+
379
506
  def _visit_ident(self, node: Identifier) -> str:
380
507
  name = node.name
381
508
  # Bare 'na' identifier → na<double>()
@@ -406,7 +533,7 @@ class ExprVisitor:
406
533
  if isinstance(val, (int, float)):
407
534
  return str(val)
408
535
  if isinstance(val, str):
409
- return f'std::string("{val}")'
536
+ return f'std::string("{self._cpp_string_escape(val)}")'
410
537
  # TA runtime-reset lowering: an input-backed var renders as its
411
538
  # override-aware getter (not the member name), because the reset may
412
539
  # run before the input members are initialised (evaluate_security path).
@@ -430,12 +557,11 @@ class ExprVisitor:
430
557
  ):
431
558
  return "(int64_t)pine_color::black"
432
559
  # Series var — read current value
433
- safe = self._safe_name(name)
434
- # Apply per-call-site var remap (for function-local vars)
435
- if self._active_var_remap and safe in self._active_var_remap:
436
- safe = self._active_var_remap[safe]
560
+ safe = self._call_site_var_name(node, self._safe_name(name))
437
561
  if self._binding_is_series(name, safe):
438
562
  return f"{safe}[0]"
563
+ if name in self._nonfinite_int_names() and name not in self._current_func_locals:
564
+ return self._nonfinite_int_read(safe)
439
565
  # Safety net: by here the name resolved to none of the builtins,
440
566
  # constants, parameters, or declared variables handled above, so
441
567
  # ``return safe`` would emit a bare identifier that is an *undeclared
@@ -457,6 +583,14 @@ class ExprVisitor:
457
583
  )
458
584
  return safe
459
585
 
586
+ def _nonfinite_int_read(self, safe: str) -> str:
587
+ """A read of a ``_nonfinite_int_names`` name: an infinity reads na, as
588
+ the int's ``na<int>()`` did, except as the operand of an ordering
589
+ comparison (``_visit_binop_operand``), which orders it as the number."""
590
+ if getattr(self, "_nonfinite_int_raw", False):
591
+ return safe
592
+ return f"(std::isfinite({safe}) ? {safe} : na<double>())"
593
+
460
594
  def _ident_is_resolvable(self, name: str) -> bool:
461
595
  """True when a bare identifier maps to a builtin, constant, parameter,
462
596
  or any declared variable/type/enum/function — i.e. emitting it will not
@@ -499,11 +633,16 @@ class ExprVisitor:
499
633
  """Lower an assignment target with UDT journal capture enabled."""
500
634
 
501
635
  previous = getattr(self, "_udt_mutable_expr_depth", 0)
636
+ previous_target = getattr(self, "_udt_assignment_target", None)
502
637
  self._udt_mutable_expr_depth = previous + 1
638
+ # A UDT array field assigned rebinds the field itself, not the array
639
+ # it holds (``_visit_member_access``).
640
+ self._udt_assignment_target = node
503
641
  try:
504
642
  return self._visit_expr(node)
505
643
  finally:
506
644
  self._udt_mutable_expr_depth = previous
645
+ self._udt_assignment_target = previous_target
507
646
 
508
647
  def _visit_member_access(self, node: MemberAccess) -> str:
509
648
  # UDT field whose type was a drawing primitive (label/line/box/
@@ -540,9 +679,18 @@ class ExprVisitor:
540
679
  or mutable_collection
541
680
  else "read"
542
681
  )
543
- return f"{arena}.{access}({owner}).{node.member}"
682
+ field = f"{arena}.{access}({owner}).{self._safe_name(node.member)}"
683
+ if (self._udt_array_field_cpp(field_spec, owner_spec.name) is not None
684
+ and node is not getattr(self, "_udt_assignment_target", None)):
685
+ # A ``_PFArrayField<T>``: every read, element write and method
686
+ # call reaches the array it holds.
687
+ return f"(*{field})"
688
+ return field
544
689
  if isinstance(node.object, Identifier):
545
690
  ns = node.object.name
691
+ if ns == "alert" and node.member in ALERT_FREQ_VALUES:
692
+ # TradingView's const string values (its own tape spells them).
693
+ return f'std::string("{ALERT_FREQ_VALUES[node.member]}")'
546
694
  if ns == "strategy":
547
695
  # Direction constants
548
696
  if node.member == "long":
@@ -654,7 +802,7 @@ class ExprVisitor:
654
802
  if ns == "ta":
655
803
  if node.member == "tr":
656
804
  return ("(std::isnan(_s_close[1]) ? "
657
- "(current_bar_.high - current_bar_.low) : "
805
+ "na<double>() : "
658
806
  "std::max(current_bar_.high - current_bar_.low, "
659
807
  "std::max(std::abs(current_bar_.high - _s_close[1]), "
660
808
  "std::abs(current_bar_.low - _s_close[1]))))")
@@ -685,7 +833,6 @@ class ExprVisitor:
685
833
  for _i, site in _candidates:
686
834
  if _i is None or _i in self._dead_ta_indices:
687
835
  continue
688
- ta_short = site.class_name.split("::")[-1].lower()
689
836
  if site.member_name.startswith(f"_ta_{node.member}_"):
690
837
  if node.member == "vwap":
691
838
  # Same implicit tail as TA_IMPLICIT_APPEND["vwap"]:
@@ -794,7 +941,7 @@ class ExprVisitor:
794
941
  if node.member == "islast":
795
942
  return "barstate_islast_"
796
943
  if node.member == "isnew":
797
- return "is_first_tick_"
944
+ return "is_first_tick()"
798
945
  if node.member == "isconfirmed":
799
946
  return "is_last_tick_"
800
947
  if node.member == "ishistory":
@@ -813,6 +960,11 @@ class ExprVisitor:
813
960
  if ns == "adjustment":
814
961
  # adjustment.none / dividends / splits. Unknown member -> "none" (0).
815
962
  return ADJUSTMENT_MAP.get(node.member, "0")
963
+ if ns == "barmerge":
964
+ # A barmerge constant held or compared as a value (the
965
+ # analyzer types it int): on 1, off 0. A request's gaps or
966
+ # lookahead reads the constant as written.
967
+ return "1" if node.member in ("gaps_on", "lookahead_on") else "0"
816
968
  if ns == "dayofweek":
817
969
  return DAYOFWEEK_MAP.get(node.member, "0")
818
970
  if ns == "session":
@@ -820,20 +972,74 @@ class ExprVisitor:
820
972
  return 'std::string("regular")'
821
973
  if node.member == "extended":
822
974
  return 'std::string("extended")'
823
- # session.is* predicates backed by engine session_ismarket_ etc.
824
- # session.isfirstbar_regular / islastbar_regular are aliased to
825
- # their non-_regular counterparts (engine has one session string;
826
- # see session_time.hpp limitation comment).
827
- if node.member == "ismarket":
828
- return "pine_session_ismarket(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
829
- if node.member == "ispremarket":
830
- return "pine_session_ispremarket(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
831
- if node.member == "ispostmarket":
832
- return "pine_session_ispostmarket(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
833
- if node.member in ("isfirstbar", "isfirstbar_regular"):
975
+ # session.isfirstbar / islastbar mark the chart's session day,
976
+ # which the engine widens by the pre- and post-market bars an
977
+ # extended-hours chart holds, and session.isfirstbar_regular /
978
+ # islastbar_regular the regular session's day: the host's
979
+ # session_is*bar_ and session_is*bar_regular_ facts
980
+ # (tests/test_e2e_session_ismarket.py).
981
+ if node.member in ("ismarket", "ispremarket", "ispostmarket"):
982
+ # A chart bar is in market when the kernel's in-session
983
+ # fact holds it (session_ismarket_): the bar read at its
984
+ # interval's first eligible instant, so a bar that opens
985
+ # in a break and holds the reopen is in market, as
986
+ # TradingView's TSE:7203 and CBOT:ZC1! 60-minute tapes
987
+ # flag it, and a D/W/M bar is
988
+ # (tests/test_e2e_session_windows.py). A run without a
989
+ # timeframe gets no such fact (script_tf_ is empty); there
990
+ # the session calendar answers at the bar's open
991
+ # (codegen/session_market.py). The time-of-day predicates
992
+ # read the instant alone: ismarket's reads a bar that
993
+ # opens in a break out of market, and until engine lane
994
+ # W11-ENG-TIME-COLOR it also missed the Sunday-evening
995
+ # open of a ":23456" session and every "0000-2400" bar
996
+ # (tests/test_e2e_session_ismarket.py).
997
+ # A bar in market is in neither extended session; off it,
998
+ # the engine's windows decide. A request.security payload
999
+ # runs on its own bars, whose timeframe the chart's facts
1000
+ # do not describe, so it keeps the predicates at the
1001
+ # security bar's time, with a warning.
1002
+ args = "(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
1003
+ predicate = f"pine_session_{node.member}{args}"
1004
+ if self._security_payload_depth:
1005
+ if id(node) not in self._warned_security_session_sites:
1006
+ self._warned_security_session_sites.add(id(node))
1007
+ # A bar that opens in a break and holds the reopen
1008
+ # is in market (the chart's reading); pre- and
1009
+ # post-market read a break as neither. A D/W/M bar
1010
+ # holds whole session days, where the predicate
1011
+ # reads its open's time of day.
1012
+ daily = {"ismarket": "always in market",
1013
+ "ispremarket": "never pre-market",
1014
+ "ispostmarket": "never post-market"}[node.member]
1015
+ opens = ("a bar that opens in a session break and holds "
1016
+ "the reopen reads as out of market, and "
1017
+ if node.member == "ismarket" else "")
1018
+ self._codegen_warning(
1019
+ node,
1020
+ f"session.{node.member} inside request.security keeps "
1021
+ "the time-of-day predicate at the security bar's open "
1022
+ f"time, which can differ from TradingView: {opens}"
1023
+ f"a D/W/M bar, {daily} on TradingView, reads its "
1024
+ "open's time of day. The chart's own session flags "
1025
+ "read the engine's session facts.",
1026
+ )
1027
+ return predicate
1028
+ self._uses_session_market = True
1029
+ ismarket = ("(script_tf_.empty() ? _pf_session_market_(syminfo_.session, "
1030
+ "syminfo_.timezone, script_tf_, current_bar_.timestamp) "
1031
+ ": session_ismarket_)")
1032
+ if node.member == "ismarket":
1033
+ return ismarket
1034
+ return f"(!{ismarket} && {predicate})"
1035
+ if node.member == "isfirstbar":
834
1036
  return "session_isfirstbar_"
835
- if node.member in ("islastbar", "islastbar_regular"):
1037
+ if node.member == "islastbar":
836
1038
  return "session_islastbar_"
1039
+ if node.member == "isfirstbar_regular":
1040
+ return "session_isfirstbar_regular_"
1041
+ if node.member == "islastbar_regular":
1042
+ return "session_islastbar_regular_"
837
1043
  return "false"
838
1044
  if ns == "syminfo":
839
1045
  _syminfo = SYMINFO_MEMBER_MAP.get(node.member)
@@ -916,7 +1122,10 @@ class ExprVisitor:
916
1122
  if name in self._enum_defs:
917
1123
  members = self._enum_defs[name]
918
1124
  if node.member in members:
919
- return f"{name}_{node.member}"
1125
+ return (
1126
+ f"{self._safe_name(name)}_"
1127
+ f"{self._safe_name(node.member)}"
1128
+ )
920
1129
 
921
1130
  # Unknown member access — emit as string constant (e.g., enum values)
922
1131
  obj = self._visit_expr(node.object)
@@ -935,9 +1144,9 @@ class ExprVisitor:
935
1144
  or name in self._current_func_locals
936
1145
  or name in self._udt_var_types
937
1146
  ):
938
- safe = self._safe_name(name)
939
- if self._active_var_remap and safe in self._active_var_remap:
940
- safe = self._active_var_remap[safe]
1147
+ safe = self._call_site_var_name(
1148
+ node.object, self._safe_name(name)
1149
+ )
941
1150
  return f"{safe}.{node.member}"
942
1151
  if name not in self.ctx.series_vars:
943
1152
  # Unknown identifier — likely an enum value
@@ -1026,6 +1235,23 @@ class ExprVisitor:
1026
1235
  f"({compare}); }}())"
1027
1236
  )
1028
1237
 
1238
+ def _relational_wrapper(self, op: str, left_node, right_node) -> str | None:
1239
+ """The wrapper ``_lower_relational`` gives a relational: ``"float"``
1240
+ (the fixed-band comparator), ``"int"`` (the KI-71 na comparator) or
1241
+ None (the plain C++ operator). Both wrappers bind each operand once,
1242
+ the left one first."""
1243
+ if op in _RELATIONAL_OPS:
1244
+ lt = self._infer_type(left_node)
1245
+ rt = self._infer_type(right_node)
1246
+ if lt in _NA_SCALAR_CPP and rt in _NA_SCALAR_CPP:
1247
+ if "double" in (lt, rt):
1248
+ return "float"
1249
+ lk = self._operand_na_kind(left_node, lt)
1250
+ rk = self._operand_na_kind(right_node, rt)
1251
+ if "int" in (lk, rk):
1252
+ return "int"
1253
+ return None
1254
+
1029
1255
  def _lower_relational(self, op: str, left_node, right_node,
1030
1256
  left_cpp: str, right_cpp: str) -> str:
1031
1257
  """Lower a Pine relational with Pine's na and float-precision rules.
@@ -1037,17 +1263,11 @@ class ExprVisitor:
1037
1263
  KI-71 wrapper only when an operand can carry the INT_MIN ``na`` sentinel.
1038
1264
  Non-relational operators and nonnumeric operands fall through unchanged.
1039
1265
  """
1040
- if op in _RELATIONAL_OPS:
1041
- lt = self._infer_type(left_node)
1042
- rt = self._infer_type(right_node)
1043
- if lt in _NA_SCALAR_CPP and rt in _NA_SCALAR_CPP:
1044
- if "double" in (lt, rt):
1045
- return self._emit_float_relational(op, left_cpp, right_cpp)
1046
- lk = self._operand_na_kind(left_node, lt)
1047
- rk = self._operand_na_kind(right_node, rt)
1048
- int_na = "int" in (lk, rk)
1049
- if int_na:
1050
- return self._emit_na_relational(op, left_cpp, right_cpp)
1266
+ wrapper = self._relational_wrapper(op, left_node, right_node)
1267
+ if wrapper == "float":
1268
+ return self._emit_float_relational(op, left_cpp, right_cpp)
1269
+ if wrapper == "int":
1270
+ return self._emit_na_relational(op, left_cpp, right_cpp)
1051
1271
  return f"({left_cpp} {op} {right_cpp})"
1052
1272
 
1053
1273
  def _visit_binop(self, node: BinOp) -> str:
@@ -1056,12 +1276,237 @@ class ExprVisitor:
1056
1276
  # C++ ``int`` literal arithmetic would wrap it. Fold it here and spell
1057
1277
  # the value as a 64-bit literal. In-range trees are emitted as before.
1058
1278
  folded = self._pure_int_literal_value(node)
1059
- if folded is not None and not self._int_fits_int32(folded):
1279
+ if (folded is not None and not self._int_fits_int32(folded)
1280
+ and self._int_fits_int64(folded)):
1060
1281
  return f"static_cast<int64_t>({folded}LL)"
1061
- left = self._visit_expr(node.left)
1062
- right = self._visit_expr(node.right)
1282
+ if node.op in ("%", "/"):
1283
+ # Both lower through doubles, which hold an int product's exact
1284
+ # value: compute a product operand in 64 bits (``_lower_binop``).
1285
+ for operand in (node.left, node.right):
1286
+ if isinstance(operand, BinOp) and operand.op == "*":
1287
+ self._wide_int_products.add(id(operand))
1288
+ left = self._visit_binop_operand(node.left, node.op)
1289
+ right = self._visit_binop_operand(node.right, node.op)
1290
+ if (self._int_arith_leaves_int32(node)
1291
+ and self._fold_int32_overflow_cpp(node.op, left, right) is None):
1292
+ # Both operands are 32-bit C++ ints and the value can leave
1293
+ # int32: Pine's int is 64-bit (``days * 86400000``). Operands the
1294
+ # C++ spells as int literals keep their fold (``_lower_binop``).
1295
+ return self._left_operand_first(
1296
+ node, left, right,
1297
+ lambda left, right: self._wide_int_arith_cpp(
1298
+ node, left, right,
1299
+ lambda left, right: self._lower_binop(node, left, right, widened=True)),
1300
+ )
1301
+ return self._left_operand_first(
1302
+ node, left, right,
1303
+ lambda left, right: self._lower_binop(node, left, right),
1304
+ )
1305
+
1306
+ def _visit_binop_operand(self, operand, op: str) -> str:
1307
+ """An operand's C++. An ordering comparison reads a
1308
+ ``_nonfinite_int_names`` name's infinity as the number TradingView
1309
+ orders it by (``_nonfinite_int_read``)."""
1310
+ if (op in self.NONFINITE_INT_ORDERING_OPS
1311
+ and isinstance(operand, Identifier)
1312
+ and operand.name in self._nonfinite_int_names()):
1313
+ previous = getattr(self, "_nonfinite_int_raw", False)
1314
+ self._nonfinite_int_raw = True
1315
+ try:
1316
+ return self._visit_expr(operand)
1317
+ finally:
1318
+ self._nonfinite_int_raw = previous
1319
+ return self._visit_expr(operand)
1320
+
1321
+ def _left_operand_first(self, node: BinOp, left: str, right: str, lower) -> str:
1322
+ """``lower(left, right)``: the C++ of ``node`` over its rendered
1323
+ operands, with the left one evaluated first when
1324
+ ``_binop_operands_need_order(node)``.
1325
+
1326
+ Pine evaluates the left operand first. C++ leaves the order of the
1327
+ operands of + - * / % and of an overloaded operator (std::string's
1328
+ + and ==, std::fmod's arguments) unspecified; GCC on x86-64 evaluates
1329
+ std::string's operator+ right operand first. Bind the left operand's
1330
+ value (a bool as bool: a std::vector<bool> element reads through a
1331
+ proxy), then evaluate the right operand. Shared by ``_visit_binop``
1332
+ and the ``request.security`` expression builder."""
1333
+ if not self._binop_operands_need_order(node):
1334
+ return lower(left, right)
1335
+ occupied = f"{left}\n{right}"
1336
+ counter = getattr(self, "_binop_lhs_counter", 0)
1337
+ while True:
1338
+ token = f"__pf_binop_lhs_{counter}"
1339
+ counter += 1
1340
+ if token not in occupied:
1341
+ break
1342
+ self._binop_lhs_counter = counter
1343
+ decl = "bool" if self._infer_type(node.left) == "bool" else "auto"
1344
+ return f"[&]{{ {decl} {token} = ({left}); return {lower(token, right)}; }}()"
1345
+
1346
+ def _binop_operands_need_order(self, node: BinOp) -> bool:
1347
+ """Whether the C++ of ``node`` must evaluate its left operand first:
1348
+ one operand has an effect (``_expr_has_ordered_effect``) the other
1349
+ one can observe (it is not ``_binop_operand_is_order_free``), and the
1350
+ lowering does not already order them (``&&`` / ``||`` and the
1351
+ relational wrappers do)."""
1352
+ if node.op in ("and", "or"):
1353
+ return False
1354
+ if not (
1355
+ (self._expr_has_ordered_effect(node.left)
1356
+ and not self._binop_operand_is_order_free(node.right))
1357
+ or (self._expr_has_ordered_effect(node.right)
1358
+ and not self._binop_operand_is_order_free(node.left))
1359
+ ):
1360
+ return False
1361
+ return self._relational_wrapper(node.op, node.left, node.right) is None
1362
+
1363
+ def _binop_operand_is_order_free(self, node) -> bool:
1364
+ """Whether no effect of the other operand can change ``node``'s value:
1365
+ a literal, a variable (a Pine function cannot assign a script
1366
+ variable, and no collection is an operand of a binary operator), or a
1367
+ ``str.*`` / ``math.*`` / ``color.*`` / ``ta.*`` call, ``nz`` / ``na``
1368
+ / a cast, or a unary, binary or conditional operation over such
1369
+ operands (a TA call's state is its call site's own)."""
1370
+ pending = [node]
1371
+ while pending:
1372
+ current = pending.pop()
1373
+ if isinstance(current, (*_ORDER_FREE_LITERALS, Identifier)):
1374
+ continue
1375
+ if isinstance(current, UnaryOp):
1376
+ pending.append(current.operand)
1377
+ elif isinstance(current, BinOp):
1378
+ pending.extend((current.left, current.right))
1379
+ elif isinstance(current, Ternary):
1380
+ pending.extend(
1381
+ (current.condition, current.true_val, current.false_val)
1382
+ )
1383
+ elif isinstance(current, FuncCall) and self._call_is_order_free(current):
1384
+ pending.extend(current.args)
1385
+ pending.extend(current.kwargs.values())
1386
+ else:
1387
+ return False
1388
+ return True
1389
+
1390
+ def _call_is_order_free(self, node: FuncCall) -> bool:
1391
+ callee = node.callee
1392
+ if isinstance(callee, Identifier):
1393
+ return (callee.name in _ORDER_FREE_CALLS
1394
+ and callee.name not in self._func_info_map)
1395
+ return (isinstance(callee, MemberAccess)
1396
+ and isinstance(callee.object, Identifier)
1397
+ and callee.object.name in _ORDER_FREE_CALL_NAMESPACES)
1398
+
1399
+ def _expr_has_ordered_effect(self, node) -> bool:
1400
+ """Whether evaluating ``node`` changes what another operand of the same
1401
+ expression can read, or does something whose order is observable: it
1402
+ mutates an array, map or matrix (``COLLECTION_MUTATING_METHODS``),
1403
+ creates, changes or deletes a drawing, places or cancels an order,
1404
+ writes a log line or an alert, stops the script, or calls a user
1405
+ function or method that does one of these or assigns a UDT field.
1406
+
1407
+ TA state and ``var`` state belong to their own call site and
1408
+ ``math.random`` draws from its call site's own stream, so none of
1409
+ them is such an effect. A method call is judged by its name alone
1410
+ (every UDT method of that name counts), since the receiver's type is
1411
+ not resolved here.
1412
+
1413
+ The walk is iterative (it adds no recursion depth to the visitor
1414
+ that asks) and reuses the answer of every expression asked before:
1415
+ an operator chain asks each of its operands once."""
1416
+ memo = getattr(self, "_ordered_effect_memo", None)
1417
+ if memo is None:
1418
+ memo = self._ordered_effect_memo = {}
1419
+ hit = memo.get(id(node))
1420
+ if hit is not None and hit[0] is node:
1421
+ return hit[1]
1422
+ result = False
1423
+ pending = [node]
1424
+ while pending:
1425
+ current = pending.pop()
1426
+ hit = memo.get(id(current))
1427
+ if hit is not None and hit[0] is current:
1428
+ if hit[1]:
1429
+ result = True
1430
+ break
1431
+ continue
1432
+ if (isinstance(current, FuncCall)
1433
+ and self._call_has_ordered_effect(current)) or (
1434
+ isinstance(current, Assignment)
1435
+ and isinstance(current.target, MemberAccess)):
1436
+ result = True
1437
+ break
1438
+ pending.extend(_ast_children(current))
1439
+ memo[id(node)] = (node, result)
1440
+ return result
1441
+
1442
+ def _call_has_ordered_effect(self, node: FuncCall) -> bool:
1443
+ """The call itself (not its arguments) is an ordered effect."""
1444
+ callee = node.callee
1445
+ if isinstance(callee, Identifier):
1446
+ if callee.name in self._func_info_map:
1447
+ return self._callable_has_ordered_effect(callee.name)
1448
+ return callee.name == "alert"
1449
+ if not isinstance(callee, MemberAccess):
1450
+ return False
1451
+ member = callee.member
1452
+ owner = callee.object
1453
+ if member in COLLECTION_MUTATING_METHODS:
1454
+ return True
1455
+ if isinstance(owner, Identifier):
1456
+ if (owner.name, member) in _ORDERED_EFFECT_CALLS:
1457
+ return True
1458
+ if (owner.name in _DRAWING_EFFECT_NAMESPACES
1459
+ and not member.startswith("get_")):
1460
+ return True
1461
+ if (isinstance(owner, MemberAccess) and owner.member == "risk"
1462
+ and isinstance(owner.object, Identifier)
1463
+ and owner.object.name == "strategy"):
1464
+ return True
1465
+ if (member.startswith(("set_", "cell_set_"))
1466
+ or member in _DRAWING_EFFECT_METHODS):
1467
+ return True
1468
+ methods = getattr(self, "_udt_method_keys_by_name", None)
1469
+ if methods is None:
1470
+ methods = self._udt_method_keys_by_name = {}
1471
+ for key, info in self._func_info_map.items():
1472
+ if getattr(info, "is_udt_method", False):
1473
+ methods.setdefault(key.rsplit(".", 1)[-1], []).append(key)
1474
+ return any(
1475
+ self._callable_has_ordered_effect(key)
1476
+ for key in methods.get(member, ())
1477
+ )
1478
+
1479
+ def _callable_has_ordered_effect(self, key: str) -> bool:
1480
+ """Whether a user function or method's body has an ordered effect."""
1481
+ memo = getattr(self, "_callable_effect_memo", None)
1482
+ if memo is None:
1483
+ memo = self._callable_effect_memo = {}
1484
+ if key not in memo:
1485
+ memo[key] = False # while its body is walked
1486
+ func_node = getattr(self._func_info_map.get(key), "node", None)
1487
+ memo[key] = func_node is not None and any(
1488
+ self._expr_has_ordered_effect(stmt) for stmt in func_node.body
1489
+ )
1490
+ return memo[key]
1491
+
1492
+ def _lower_binop(self, node: BinOp, left: str, right: str, widened: bool = False) -> str:
1493
+ """The C++ of ``node`` over its rendered operands; ``widened`` when
1494
+ ``_visit_binop`` computes it in 64 bits already (``left`` cast)."""
1063
1495
  cpp_ops = {"and": "&&", "or": "||"}
1064
1496
  op = cpp_ops.get(node.op, node.op)
1497
+ if node.op in ("and", "or"):
1498
+ # Pine converts numeric operands to two-state booleans before
1499
+ # applying short-circuit logic. C++ treats NaN and INT_MIN as
1500
+ # true, so both operands need the same na-aware truthiness rule.
1501
+ left = self._coerce_bool_expr(left, node.left)
1502
+ right = self._coerce_bool_expr(right, node.right)
1503
+ if self._pine_v5_body:
1504
+ # v5 evaluates both operands, left then right, whatever the
1505
+ # left one decides (TradingView: a stateful right operand
1506
+ # runs on every bar).
1507
+ return (f"([&]() -> bool {{ const bool _pf_v5_l = ({left}); "
1508
+ f"const bool _pf_v5_r = ({right}); "
1509
+ f"return _pf_v5_l {op} _pf_v5_r; }}())")
1065
1510
  if node.op == "+":
1066
1511
  lt = self._infer_type(node.left)
1067
1512
  rt = self._infer_type(node.right)
@@ -1083,37 +1528,244 @@ class ExprVisitor:
1083
1528
  # Ref: https://www.tradingview.com/pine-script-docs/concepts/operators/
1084
1529
  if node.op == "/":
1085
1530
  return f"((double)({left}) / (double)({right}))"
1531
+ if node.op in ("==", "!=") and self._pine_v5_body:
1532
+ self._refuse_v5_bool_na_observer(
1533
+ node, f"'{node.op}'", (node.left, node.right))
1534
+ # Operands that render as C++ ``int`` literal arithmetic although
1535
+ # the tree above did not fold (a name spelled otherwise than
1536
+ # ``_inlined_int_constant`` reads it): a result beyond int32 is a
1537
+ # 64-bit Pine int, never a C++ overflow.
1538
+ folded_cpp = self._fold_int32_overflow_cpp(node.op, left, right)
1539
+ if folded_cpp is not None:
1540
+ return folded_cpp
1541
+ if (node.op == "*" and not widened and id(node) in self._wide_int_products
1542
+ and self._pure_int_literal_value(node) is None
1543
+ and self._emits_int32(node.left) and self._emits_int32(node.right)):
1544
+ # Pine's int is 64-bit. A product a ``%`` or ``/`` reads reaches a
1545
+ # double whole, where the C++ ``int`` product wrapped: the
1546
+ # Park-Miller step ``(s * 48271) % 2147483647``
1547
+ # (``fixtures/tail_f_tv/int_product``). A product that can leave
1548
+ # int32 by its operands' bounds is ``widened`` already, na-aware
1549
+ # (``_visit_binop``, ``_wide_int_arith_cpp``), and keeps that
1550
+ # spelling; this covers the rest a ``%`` or ``/`` reads (an
1551
+ # ``array.get`` operand, which the bounds do not know).
1552
+ return f"((int64_t)({left}) * ({right}))"
1086
1553
  return self._lower_relational(op, node.left, node.right, left, right)
1087
1554
 
1555
+ def _emits_int32(self, node) -> bool:
1556
+ """Whether ``node`` is emitted as a C++ ``int`` for certain: an int
1557
+ literal, a name stored as ``int``, an element of an ``array<int>``
1558
+ (read or iterated), or a sign, sum or difference of those."""
1559
+ if isinstance(node, NumberLiteral):
1560
+ return isinstance(node.value, int) and not isinstance(node.value, bool)
1561
+ if isinstance(node, UnaryOp) and node.op in ("-", "+"):
1562
+ return self._emits_int32(node.operand)
1563
+ if isinstance(node, BinOp) and node.op in ("+", "-"):
1564
+ return self._emits_int32(node.left) and self._emits_int32(node.right)
1565
+ if isinstance(node, Identifier):
1566
+ if node.name in getattr(self, "_current_loop_vars", set()):
1567
+ # A ``for ... in`` element: an int only over an ``array<int>``.
1568
+ spec = getattr(self, "_current_loop_var_specs", {}).get(node.name)
1569
+ return (spec is not None and spec.kind == "primitive"
1570
+ and self._type_spec_to_cpp(spec) == "int")
1571
+ return (not self._emitted_value_is_double(node)
1572
+ and self._slot_scalar_cpp_type(node.name) == "int"
1573
+ and self._infer_type(node) == "int")
1574
+ if isinstance(node, FuncCall):
1575
+ callee = node.callee
1576
+ if not isinstance(callee, MemberAccess) or callee.member != "get":
1577
+ return False
1578
+ receiver = (callee.object if not (
1579
+ isinstance(callee.object, Identifier) and callee.object.name == "array")
1580
+ else (node.args[0] if node.args else None))
1581
+ if not isinstance(receiver, Identifier):
1582
+ return False
1583
+ spec = self._collection_spec_for_name(receiver.name)
1584
+ return (spec is not None and spec.kind == "array" and spec.element is not None
1585
+ and self._type_spec_to_cpp(spec.element) == "int")
1586
+ return False
1587
+
1588
+ def _refuse_v5_bool_na_observer(self, node, what: str, operands) -> None:
1589
+ """A v5 bool can be na (a comparison with na, an na literal, a bool's
1590
+ history before the first bar); PineForge's bool cannot. It reads as
1591
+ false wherever v5 casts it to a bool (TradingView's tape of
1592
+ ``xc_v5_lib``), but ``na()``, ``nz()``, ``fixnan()``,
1593
+ ``str.tostring``/``str.format`` and ``==``/``!=`` of a bool tell the
1594
+ third state apart (a v5 probe's tape outside this repository:
1595
+ ``na(b)`` true, ``b == false`` na)."""
1596
+ if any(self._infer_type(o) == "bool" for o in operands if o is not None):
1597
+ library = (getattr(node.loc, "file", None) or "a v5 library")
1598
+ self._codegen_error(
1599
+ node,
1600
+ f"library '{library}' is //@version=5: {what} of a bool reads "
1601
+ "v5's third bool state (na), which PineForge's two-state bool "
1602
+ "does not keep",
1603
+ )
1604
+
1088
1605
  def _visit_unaryop(self, node: UnaryOp) -> str:
1089
1606
  operand = self._visit_expr(node.operand)
1090
1607
  if node.op == "not":
1091
- return f"!({operand})"
1092
- return f"({node.op}{operand})"
1608
+ return f"!({self._coerce_bool_expr(operand, node.operand)})"
1609
+ return unary_sign_cpp(node.op, operand)
1610
+
1611
+ def _visit_session_history(self, node: Subscript, series_idx: str) -> str:
1612
+ """``session.<flag>[k]``: the value the flag had k bars ago at the top
1613
+ level of the script, k calls ago in a function body.
1614
+
1615
+ TradingView's tapes (``tests/test_e2e_session_history.py``) read a
1616
+ top-level offset by bars, in a block and on a lazy operand too, and a
1617
+ function body's by the calls of its call site, whichever operand or
1618
+ block holds the read. A top-level read indexes the flag's Series,
1619
+ pushed on every chart bar (``_prescan_session_history``); a function
1620
+ body's indexes its emitted call site's Series, pushed at the function's
1621
+ entry (``_prepare_inline_history_members``). A request.security
1622
+ expression that reaches the read through its own operators builds it
1623
+ on the requested clock (security.py); one that reaches it through a
1624
+ call's argument or a variable has no such history here and is refused
1625
+ at the read. So is a read emitted in a function the analyzer lists in
1626
+ ``session_history_unsafe`` (a method, or a function a method, a
1627
+ request.security expression or a UDT field default reaches), and one
1628
+ with no registered Series: never shared state. The refusal waits for
1629
+ the C++ (``_settle_session_reads``): a read in an argument the codegen
1630
+ renders and leaves out refuses nothing. A read in a function this
1631
+ analysis did not clone (``session_uncloned``) asks for its clones
1632
+ instead.
1633
+ """
1634
+ flag = node.object.member
1635
+ where = next((n for n in (node, node.index, node.object)
1636
+ if getattr(n, "loc", None) is not None), node)
1637
+ if self._security_payload_depth:
1638
+ return self._refused_session_read(
1639
+ node, where,
1640
+ f"session.{flag}[...] cannot be read here: PineForge keeps a session "
1641
+ "flag's history on the requested clock only for a read the "
1642
+ "request.security expression reaches through its own operators, "
1643
+ "not through a call's argument, a function or a variable.",
1644
+ )
1645
+ owner = self._session_call_owner.get(id(node))
1646
+ if owner is not None:
1647
+ why = (getattr(self.ctx, "session_history_unsafe", None) or {}).get(owner)
1648
+ if why:
1649
+ return self._refused_session_read(
1650
+ node, where,
1651
+ f"session.{flag}[...] cannot be read in {owner.split('.')[-1]}(), "
1652
+ f"{why}: PineForge keeps a session flag's history in a function by "
1653
+ "each of its call sites, and cannot tell this one apart.",
1654
+ )
1655
+ member = self._inline_history_member_by_key.get(
1656
+ ("session_call", owner, flag, self._current_instance_name))
1657
+ if member is not None:
1658
+ return f"{member}[{series_idx}]"
1659
+ elif flag in self._session_history_flags:
1660
+ return f"{self._session_history_member(flag)}[{series_idx}]"
1661
+ uncloned = getattr(self.ctx, "session_uncloned", None) or ()
1662
+ return self._refused_session_read(
1663
+ node, where,
1664
+ f"session.{flag}[...] is not supported here: PineForge keeps a session "
1665
+ "flag's history for the script's top level, a function body and a "
1666
+ "request.security expression that reads it.",
1667
+ owner if owner in uncloned else None,
1668
+ )
1669
+
1670
+ def _refused_session_read(self, node: Subscript, where, message: str,
1671
+ uncloned: str | None = None) -> str:
1672
+ """A stand-in for a ``session.<flag>[k]`` read PineForge keeps no
1673
+ history for: a name only this read spells, the refusal raised (or, in
1674
+ the uncloned function ``uncloned``, its clones asked for) if the
1675
+ emitted code holds it (``_settle_session_reads``)."""
1676
+ key = (id(node), message)
1677
+ name = self._refused_session_read_names.get(key)
1678
+ if name is None:
1679
+ name = self._allocate_generated_cpp_name(
1680
+ f"_refused_session_read_{len(self._refused_session_reads) + 1}",
1681
+ self._session_names_used)
1682
+ self._refused_session_read_names[key] = name
1683
+ self._refused_session_reads[name] = (node, where, message, uncloned)
1684
+ return name
1685
+
1686
+ def _call_site_var_name(self, node: Identifier, safe: str) -> str:
1687
+ """``safe`` as this read names it under the active var remap.
1688
+
1689
+ In a callable body the clone remap lists every callable's history and
1690
+ ``var`` members, for nested instances to compose, so it only renames a
1691
+ local binding. A read the analyzer resolved to a script-scope binding
1692
+ (also before a same-named local shadows it), a loop binder or a
1693
+ parameter keeps its own name: renaming ``src`` to another callable's
1694
+ ``src_cs1``, which nothing writes, read ``na``. Outside callables the
1695
+ remap holds block-scoped renames only, which a top-level branch
1696
+ binding resolved to the global scope still needs.
1697
+ """
1698
+ if not self._active_var_remap or safe not in self._active_var_remap:
1699
+ return safe
1700
+ if getattr(self, "_active_func_name", None) is not None and (
1701
+ self.ctx.identifier_binding_scopes.get(id(node)) == "global"
1702
+ or node.name in self._current_loop_vars
1703
+ or node.name in self._current_func_param_types
1704
+ ):
1705
+ return safe
1706
+ return self._active_var_remap[safe]
1707
+
1708
+ def _history_offset_cpp(self, idx: str, index_node) -> str:
1709
+ """The ``int`` offset of a history read, safe right after a ``[``.
1710
+
1711
+ A double index narrows through the na-preserving lambda
1712
+ ``[&](){ ... }()`` (``_coerce_int_slot``); spelled straight after a
1713
+ subscript's ``[`` it reads ``[[``, which C++ parses as an attribute,
1714
+ so a lambda offset is parenthesized. TradingView truncates a
1715
+ fractional index toward zero (``x[5.5]`` reads ``x[5]``), as the
1716
+ cast does.
1717
+ """
1718
+ idx_int = self._coerce_int_slot(idx, index_node, "int")
1719
+ if (idx_int == idx
1720
+ and not self._emitted_value_is_double(index_node)):
1721
+ idx_int = pine_index_int_cast(idx)
1722
+ if idx_int.startswith("["):
1723
+ idx_int = f"({idx_int})"
1724
+ return idx_int
1093
1725
 
1094
1726
  def _visit_subscript(self, node: Subscript) -> str:
1095
1727
  idx = self._visit_expr(node.index)
1728
+ # Series::operator[] accepts C++ int. A Pine int can be backed by an
1729
+ # int64_t timestamp slot; implicitly narrowing its na sentinel to int
1730
+ # turns it into zero on arm64 and reads the current bar. Preserve the
1731
+ # original value type before narrowing every dynamic series offset.
1732
+ series_idx = (
1733
+ idx if (isinstance(node.index, NumberLiteral)
1734
+ and isinstance(node.index.value, int))
1735
+ else pine_index_int_cast(idx)
1736
+ )
1096
1737
  if isinstance(node.object, Identifier):
1097
1738
  name = node.object.name
1739
+ # A script variable read through history in a plain UDF body: the
1740
+ # call site's own history of it.
1741
+ if id(node) in self.ctx.func_global_history_nodes:
1742
+ member = self._function_global_history_member(name)
1743
+ if member is not None:
1744
+ return f"{member}[{series_idx}]"
1098
1745
  # Function parameters that are series — src[N] → src[N]
1099
1746
  if name in self._current_func_series_params:
1100
- return f"{self._safe_name(name)}[{idx}]"
1747
+ return f"{self._safe_name(name)}[{series_idx}]"
1101
1748
  # Function parameters are scalars — src[0] → src, src[N>0] → src
1102
1749
  if name in self._current_func_param_types:
1103
1750
  return self._safe_name(name)
1104
1751
  if name in BAR_FIELDS or name in BAR_SERIES_PUSH:
1105
1752
  # Index matches Pine: [0] current bar, [k] k bars ago (runtime Series deque).
1106
- return f"_s_{name}[{idx}]"
1107
- safe = self._safe_name(name)
1753
+ return f"_s_{name}[{series_idx}]"
1108
1754
  # Apply per-call-site / exact block-member remap before deciding
1109
1755
  # whether the current lexical binding is a Series.
1110
- if self._active_var_remap and safe in self._active_var_remap:
1111
- safe = self._active_var_remap[safe]
1756
+ safe = self._call_site_var_name(node.object, self._safe_name(name))
1112
1757
  if self._binding_is_series(name, safe):
1113
1758
  # Same Pine [k] semantics as Series in runtime/series.hpp
1114
- return f"{safe}[{idx}]"
1759
+ return f"{safe}[{series_idx}]"
1115
1760
  spec = self._collection_spec_for_name(name)
1116
1761
  if spec is not None and spec.kind in ("array", "map"):
1762
+ self._codegen_warning(
1763
+ node,
1764
+ f"{spec.kind} history indexing uses the current collection "
1765
+ "element in PineForge; the engine does not retain per-bar "
1766
+ "collection IDs, so this result can differ from TradingView "
1767
+ "and a missing index is not represented faithfully.",
1768
+ )
1117
1769
  return f"{self._collection_receiver_expr(name)}[{idx}]"
1118
1770
  # Handle strategy.* history access (e.g., strategy.position_size[1])
1119
1771
  if isinstance(node.object, MemberAccess):
@@ -1125,7 +1777,9 @@ class ExprVisitor:
1125
1777
  series_name = f"_strat_{member}"
1126
1778
  if series_name not in self._strategy_series_vars:
1127
1779
  self._strategy_series_vars.add(series_name)
1128
- return f"{series_name}[{idx}]"
1780
+ return f"{series_name}[{series_idx}]"
1781
+ if self._is_session_flag(node.object):
1782
+ return self._visit_session_history(node, series_idx)
1129
1783
  # History reference applied directly to an inline call result, e.g.
1130
1784
  # ``ta.highest(high, 10)[1]`` or ``f()[2]``. In Pine the call yields a
1131
1785
  # series, so ``[k]`` reads its value k bars ago — but the call lowers to
@@ -1144,7 +1798,8 @@ class ExprVisitor:
1144
1798
  # previous chart bar in every run mode (``_lazy_edge_ta_hoist_plan``).
1145
1799
  hoisted_member = self._hoisted_hist_reads.get(id(node))
1146
1800
  if hoisted_member is not None:
1147
- return f"{hoisted_member}[(int)({idx})]"
1801
+ idx_int = self._history_offset_cpp(idx, node.index)
1802
+ return f"{hoisted_member}[{idx_int}]"
1148
1803
  inner = self._visit_expr(node.object)
1149
1804
  cpp_t = self._infer_type(node.object)
1150
1805
  if cpp_t not in ("double", "int", "int64_t", "bool"):
@@ -1175,6 +1830,7 @@ class ExprVisitor:
1175
1830
  # established call-local history fallback below.
1176
1831
  ta_mem = self._ta_member_name(ta_site)
1177
1832
  precalc = f"_precalc_{ta_mem}"
1833
+ idx_int = self._history_offset_cpp(idx, node.index)
1178
1834
  return (
1179
1835
  f"([&]() -> {cpp_t} {{ "
1180
1836
  f"if (_use_precalc) {{ "
@@ -1196,15 +1852,39 @@ class ExprVisitor:
1196
1852
  f"{cpp_t} _hv = ({inner}); "
1197
1853
  f"if (history_advances_new_bar()) {member}.push(_hv); "
1198
1854
  f"else {member}.update(_hv); "
1199
- f"return {member}[(int)({idx})]; }}())"
1855
+ f"return {member}[{idx_int}]; }}())"
1200
1856
  )
1857
+ idx_int = self._history_offset_cpp(idx, node.index)
1201
1858
  return (
1202
1859
  f"([&]() -> {cpp_t} {{ "
1203
1860
  f"{cpp_t} _hv = ({inner}); "
1204
1861
  f"if (history_advances_new_bar()) {member}.push(_hv); "
1205
1862
  f"else {member}.update(_hv); "
1206
- f"return {member}[(int)({idx})]; }}())"
1863
+ f"return {member}[{idx_int}]; }}())"
1864
+ )
1865
+ # History on an operator expression (``(a > b)[1]``) reads the value
1866
+ # the expression had k bars ago: its prepass-registered synthetic
1867
+ # Series (``_is_compound_history_object``) is pushed once per
1868
+ # evaluation, like the inline call results above.
1869
+ compound_member = (
1870
+ self._inline_history_member_by_key.get(
1871
+ ("hist_call", id(node), self._current_instance_name)
1207
1872
  )
1873
+ if isinstance(node.object, (BinOp, UnaryOp, Ternary))
1874
+ else None
1875
+ )
1876
+ if compound_member is not None:
1877
+ cpp_t = self._infer_type(node.object)
1878
+ if cpp_t in ("double", "int", "int64_t", "bool"):
1879
+ inner = self._visit_expr(node.object)
1880
+ idx_int = self._history_offset_cpp(idx, node.index)
1881
+ return (
1882
+ f"([&]() -> {cpp_t} {{ "
1883
+ f"{cpp_t} _hv = ({inner}); "
1884
+ f"if (history_advances_new_bar()) {compound_member}.push(_hv); "
1885
+ f"else {compound_member}.update(_hv); "
1886
+ f"return {compound_member}[{idx_int}]; }}())"
1887
+ )
1208
1888
  obj = self._visit_expr(node.object)
1209
1889
  # If subscripting a non-series variable (e.g., function parameter),
1210
1890
  # src[0] → src (current value), src[N>0] → src (can't access history)
@@ -1214,4 +1894,5 @@ class ExprVisitor:
1214
1894
  and name not in self.ctx.series_vars
1215
1895
  and name not in self._var_names):
1216
1896
  return obj
1217
- return f"{obj}[{idx}]"
1897
+ idx_int = self._history_offset_cpp(idx, node.index)
1898
+ return f"{obj}[{idx_int}]"