@pineforge/codegen-pyodide 0.10.1 → 0.10.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineforge/codegen-pyodide",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Gate-validated Pyodide payload for the PineScript v6 -> C++ transpiler: archive (run in Pyodide), unpacked source, introspected tables, and release metadata.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -514,6 +514,15 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
514
514
  # the declaration-derived checkpoint inventory.
515
515
  self._inline_history_members: list[dict] = []
516
516
  self._inline_history_member_by_key: dict[tuple, str] = {}
517
+ # request.security/request.security_lower_tf inline their Pine helper
518
+ # expressions on the requested clock instead of calling the ordinary
519
+ # emitted UDF methods. The chart-clocked plain-UDF history factor must
520
+ # therefore exclude those source nodes and any emitted helper context
521
+ # reached only through them.
522
+ self._requested_context_inline_node_ids: set[int] = set()
523
+ self._requested_context_only_inline_contexts: set[
524
+ tuple[str, str | None]
525
+ ] = set()
517
526
  # Unique lambda-local names used when an array lowering references its
518
527
  # receiver more than once. The binding keeps temporary-producing or
519
528
  # side-effectful receivers single-evaluation (see TypeInferer).
@@ -3173,7 +3182,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3173
3182
  """
3174
3183
  self._inline_history_members = []
3175
3184
  self._inline_history_member_by_key = {}
3176
- counters = {"hist_call": 0, "series_arg": 0}
3185
+ counters = {
3186
+ "hist_call": 0,
3187
+ "series_arg": 0,
3188
+ "udf_series_arg": 0,
3189
+ }
3177
3190
 
3178
3191
  def walk_nodes(value):
3179
3192
  """Yield AST nodes in stable field order, including tuple elements.
@@ -3345,6 +3358,113 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3345
3358
  lexical.update(self._func_collection_types.get(owner, {}))
3346
3359
  return lexical
3347
3360
 
3361
+ def plain_udf_info(call: FuncCall):
3362
+ if not isinstance(call.callee, Identifier):
3363
+ return None
3364
+ candidate = self._func_info_map.get(call.callee.name)
3365
+ if (
3366
+ candidate is None
3367
+ or candidate.node is None
3368
+ or getattr(candidate, "is_udt_method", False)
3369
+ ):
3370
+ return None
3371
+ return candidate
3372
+
3373
+ # Requested-context UDF expressions are lowered independently by
3374
+ # security.py. Classify their otherwise-emitted helper variants so
3375
+ # this chart-clocked factor neither allocates unused buffers for them
3376
+ # nor changes the legacy bodies that remain in generated C++.
3377
+ requested_node_ids: set[int] = set()
3378
+ for security_call in self._security_calls:
3379
+ expression = security_call.get("expr_node")
3380
+ if expression is None:
3381
+ continue
3382
+ requested_node_ids.update(
3383
+ id(child) for child in walk_nodes(expression)
3384
+ )
3385
+ self._requested_context_inline_node_ids = requested_node_ids
3386
+
3387
+ def emitted_context_for_call(
3388
+ fi,
3389
+ call: FuncCall,
3390
+ owner: str | None,
3391
+ owner_context: str | None,
3392
+ ) -> str | None:
3393
+ dispatch = self._instance_dispatch.get(
3394
+ (owner_context, id(call))
3395
+ )
3396
+ if dispatch is not None:
3397
+ return dispatch
3398
+ target_cs = target_cs_for_context(
3399
+ fi, call, owner, owner_context
3400
+ )
3401
+ if target_cs is not None:
3402
+ return f"{self._func_cpp_base_name(fi.name)}_cs{target_cs}"
3403
+ return None
3404
+
3405
+ requested_roots: set[tuple[str, str | None]] = set()
3406
+ chart_roots: set[tuple[str, str | None]] = set()
3407
+ context_edges: dict[
3408
+ tuple[str, str | None], set[tuple[str, str | None]]
3409
+ ] = {}
3410
+ for call in (
3411
+ candidate
3412
+ for candidate in walk_nodes(self.ctx.ast)
3413
+ if isinstance(candidate, FuncCall)
3414
+ ):
3415
+ fi = plain_udf_info(call)
3416
+ if fi is None:
3417
+ continue
3418
+ owner = owner_by_node.get(id(call))
3419
+ if id(call) in requested_node_ids:
3420
+ owner_contexts = (
3421
+ [None]
3422
+ if owner is None
3423
+ else self._inline_history_contexts_for_owner(owner)
3424
+ )
3425
+ for owner_context in owner_contexts:
3426
+ requested_roots.add((
3427
+ fi.name,
3428
+ emitted_context_for_call(
3429
+ fi, call, owner, owner_context
3430
+ ),
3431
+ ))
3432
+ continue
3433
+ if owner is None:
3434
+ target = (
3435
+ fi.name,
3436
+ emitted_context_for_call(fi, call, None, None),
3437
+ )
3438
+ chart_roots.add(target)
3439
+ continue
3440
+ for owner_context in self._inline_history_contexts_for_owner(owner):
3441
+ source = (owner, owner_context)
3442
+ target = (
3443
+ fi.name,
3444
+ emitted_context_for_call(
3445
+ fi, call, owner, owner_context
3446
+ ),
3447
+ )
3448
+ context_edges.setdefault(source, set()).add(target)
3449
+
3450
+ def reachable(
3451
+ roots: set[tuple[str, str | None]],
3452
+ ) -> set[tuple[str, str | None]]:
3453
+ found = set(roots)
3454
+ pending = list(roots)
3455
+ while pending:
3456
+ source = pending.pop()
3457
+ for target in context_edges.get(source, ()):
3458
+ if target in found:
3459
+ continue
3460
+ found.add(target)
3461
+ pending.append(target)
3462
+ return found
3463
+
3464
+ self._requested_context_only_inline_contexts = (
3465
+ reachable(requested_roots) - reachable(chart_roots)
3466
+ )
3467
+
3348
3468
  for node in walk_nodes(self.ctx.ast):
3349
3469
  owner = owner_by_node.get(id(node))
3350
3470
  if isinstance(node, Subscript) and isinstance(node.object, FuncCall):
@@ -3403,6 +3523,26 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3403
3523
  expected_cpp_type = self._series_param_element_cpp_type(
3404
3524
  fi, idx, target_cs
3405
3525
  )
3526
+ # Pine gives each plain-UDF written call its own
3527
+ # chart-aligned parameter history. Even an exact Series
3528
+ # actual therefore needs a call-site-owned buffer: skipped
3529
+ # evaluations hold the last value in that bar's slot
3530
+ # instead of exposing the caller's independently changing
3531
+ # history. Typed methods retain their established direct
3532
+ # binding/bridge behavior below.
3533
+ chart_execution_context = (
3534
+ id(node) not in requested_node_ids
3535
+ and (owner, context)
3536
+ not in self._requested_context_only_inline_contexts
3537
+ )
3538
+ if not method_call and chart_execution_context:
3539
+ register_one(
3540
+ "udf_series_arg",
3541
+ (id(node), idx),
3542
+ expected_cpp_type,
3543
+ context,
3544
+ )
3545
+ continue
3406
3546
  needs_bridge = True
3407
3547
  if isinstance(arg, Identifier):
3408
3548
  if (
@@ -949,6 +949,21 @@ class TopLevelEmitter:
949
949
  f"{info['member_name']}.clear();"
950
950
  )
951
951
 
952
+ # A history-reading plain-UDF parameter advances on the chart clock,
953
+ # even when lazy control flow skips its written call on this bar. Seed
954
+ # the new slot with the prior current value (``na`` before first reach);
955
+ # an executed call later in the bar updates this same slot with its
956
+ # scalar actual. Typed-method ``series_arg`` bridges intentionally keep
957
+ # their existing execution-clock behavior.
958
+ for info in self._inline_history_members:
959
+ if info["kind"] != "udf_series_arg":
960
+ continue
961
+ member = info["member_name"]
962
+ lines.append(
963
+ f" if (history_advances_new_bar()) "
964
+ f"{member}.push({member}.current());"
965
+ )
966
+
952
967
  # a. Push bar field series (with bar magnifier support)
953
968
  for field_name in sorted(self.ctx.series_bar_fields):
954
969
  push_expr = BAR_SERIES_PUSH.get(field_name, f"current_bar_.{field_name}")
@@ -2844,6 +2844,156 @@ class SecurityEmitter:
2844
2844
  )
2845
2845
  return out
2846
2846
 
2847
+ def _security_lazy_ta_keys(
2848
+ self,
2849
+ sec_id: int,
2850
+ expr_node,
2851
+ info: dict,
2852
+ ) -> set[tuple[int, tuple]]:
2853
+ """``(ta_index, binding_signature)`` pairs Pine reaches only lazily.
2854
+
2855
+ Pine evaluates ``and``/``or`` with left-to-right short-circuit and a
2856
+ ternary evaluates its condition plus exactly one branch. A ``ta.*``
2857
+ call sitting in a skipped operand is **not evaluated on that bar**, so
2858
+ its series state does not advance. The evaluator used to hoist every
2859
+ collected site into an unconditional ``auto _secval_N = ...compute(...)``
2860
+ prologue, which advanced every site on every HTF bar and desynchronised
2861
+ the series against TradingView.
2862
+
2863
+ A site returned here is dropped from that prologue. ``_build_security_expr``
2864
+ then falls through to its inline
2865
+ ``(security_series_slot_is_new(N) ? m.compute(a) : m.recompute(a))``
2866
+ form, emitted in expression position — C++'s own ``&&`` / ``||`` /
2867
+ ``?:`` short-circuit then advances the state exactly when Pine would,
2868
+ and the relational lowering's ``_pna_l`` / ``_pna_r`` temporaries keep
2869
+ the evaluation order left-to-right.
2870
+
2871
+ Deliberately conservative — anything not provably single-reach and
2872
+ conditional keeps the existing eager hoist, so scripts without
2873
+ conditional security TA regenerate byte-identically:
2874
+
2875
+ - A site reached more than once must stay hoisted: sharing one
2876
+ ``_secval_*`` is what keeps it to a single advance per bar, whereas
2877
+ two inline copies would advance it twice (or zero times).
2878
+ - Laziness does **not** propagate through a global binding. In the
2879
+ requested context that global is its own unconditional top-level
2880
+ statement, so it evaluates on every HTF bar wherever it is read
2881
+ (this is what keeps chart-side ``t0``-shaped code exact).
2882
+ - Sites with a history offset (``ta.ema(close, 55)[1]``) keep the
2883
+ committed ``_secval_*`` their per-bar Series push reads.
2884
+ - Securities that rebind mutable globals, and multi-statement helper
2885
+ calls, bail entirely: both lower through statement emitters that
2886
+ consume ``ta_results`` outside this expression.
2887
+ """
2888
+ if info.get("mutable_globals"):
2889
+ return set()
2890
+
2891
+ occurrences: dict[tuple[int, tuple], list[bool]] = {}
2892
+ unsafe = False
2893
+
2894
+ def walk(node, lazy: bool, binding_stack, resolving: set[str]) -> None:
2895
+ nonlocal unsafe
2896
+ if node is None or unsafe:
2897
+ return
2898
+
2899
+ if isinstance(node, Identifier):
2900
+ binding = None
2901
+ if not self._security_identifier_is_global_binding(node):
2902
+ binding = self._security_lookup_helper_binding_context(
2903
+ node.name, binding_stack
2904
+ )
2905
+ if binding is not None:
2906
+ bound, bound_stack = binding
2907
+ if isinstance(bound, str):
2908
+ return
2909
+ bind_key = f"bind:{id(bound)}"
2910
+ if bind_key in resolving:
2911
+ return
2912
+ resolving.add(bind_key)
2913
+ walk(bound, lazy, bound_stack, resolving)
2914
+ resolving.discard(bind_key)
2915
+ return
2916
+
2917
+ if self._global_mutable_infos.get(node.name) is not None:
2918
+ unsafe = True
2919
+ return
2920
+
2921
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
2922
+ if (
2923
+ self._security_identifier_is_global_binding(node)
2924
+ and node.name in global_expr_map
2925
+ and node.name not in resolving
2926
+ ):
2927
+ resolving.add(node.name)
2928
+ walk(global_expr_map[node.name], False, (), resolving)
2929
+ resolving.remove(node.name)
2930
+ return
2931
+
2932
+ if isinstance(node, FuncCall) and isinstance(node.callee, Identifier):
2933
+ func_name = node.callee.name
2934
+ if func_name in self._func_names:
2935
+ call_key = f"func:{func_name}"
2936
+ if call_key in resolving:
2937
+ return
2938
+ resolving.add(call_key)
2939
+ plan = self._security_helper_call_plan(node, binding_stack)
2940
+ if plan["mode"] == "expr":
2941
+ walk(plan["expr"], lazy, plan["binding_stack"], resolving)
2942
+ else:
2943
+ unsafe = True
2944
+ resolving.discard(call_key)
2945
+ return
2946
+
2947
+ if isinstance(node, BinOp) and node.op in ("and", "or"):
2948
+ walk(node.left, lazy, binding_stack, resolving)
2949
+ walk(node.right, True, binding_stack, resolving)
2950
+ return
2951
+
2952
+ if isinstance(node, Ternary):
2953
+ walk(node.condition, lazy, binding_stack, resolving)
2954
+ walk(node.true_val, True, binding_stack, resolving)
2955
+ walk(node.false_val, True, binding_stack, resolving)
2956
+ return
2957
+
2958
+ site = self._get_ta_site(node)
2959
+ if site is not None:
2960
+ idx = self._ta_index_by_site_id.get(id(site))
2961
+ if idx is not None:
2962
+ key = (idx, self._security_binding_stack_signature(binding_stack))
2963
+ occurrences.setdefault(key, []).append(lazy)
2964
+
2965
+ for child in vars(node).values():
2966
+ walk_value(child, lazy, binding_stack, resolving)
2967
+
2968
+ def walk_value(value, lazy: bool, binding_stack, resolving: set[str]) -> None:
2969
+ if value is None or unsafe:
2970
+ return
2971
+ if hasattr(value, "__dict__"):
2972
+ walk(value, lazy, binding_stack, resolving)
2973
+ return
2974
+ if isinstance(value, (list, tuple)):
2975
+ for item in value:
2976
+ walk_value(item, lazy, binding_stack, resolving)
2977
+ return
2978
+ if isinstance(value, dict):
2979
+ for item in value.values():
2980
+ walk_value(item, lazy, binding_stack, resolving)
2981
+
2982
+ walk(expr_node, False, (), set())
2983
+ if unsafe:
2984
+ return set()
2985
+
2986
+ hist_indices = set(self._security_ta_hist_idx_by_sec.get(sec_id, ()))
2987
+ inline_helper_ta_indices = set(info.get("inline_helper_ta_indices", []))
2988
+ return {
2989
+ key
2990
+ for key, reaches in occurrences.items()
2991
+ if key[0] not in hist_indices
2992
+ and key[0] not in inline_helper_ta_indices
2993
+ and len(reaches) == 1
2994
+ and reaches[0]
2995
+ }
2996
+
2847
2997
  def _emit_security_ohlc_hist_pushes(self, sec_id: int, lines: list[str]) -> None:
2848
2998
  """Emit the OHLC history-offset Series pushes for ``sec_id``, gated on
2849
2999
  ``is_complete``.
@@ -2909,6 +3059,7 @@ class SecurityEmitter:
2909
3059
  ta_indices = info.get("ta_indices") or []
2910
3060
  security_mutable_names = set(info.get("mutable_globals", []))
2911
3061
  inline_helper_ta_indices = set(info.get("inline_helper_ta_indices", []))
3062
+ lazy_ta_keys = self._security_lazy_ta_keys(sec_id, expr_node, info)
2912
3063
 
2913
3064
  lines.append(f" void _eval_security_{sec_id}(const Bar& bar, bool is_complete) {{")
2914
3065
 
@@ -2949,6 +3100,15 @@ class SecurityEmitter:
2949
3100
  site = self.ctx.ta_call_sites[idx]
2950
3101
  variants = (info.get("ta_variants") or {}).get(idx, [])
2951
3102
  for variant in variants:
3103
+ if (idx, variant["signature"]) in lazy_ta_keys:
3104
+ # Pine only reaches this site through a
3105
+ # short-circuited operand or an untaken ternary
3106
+ # branch. Leave it out of the eager prologue:
3107
+ # _build_security_expr emits its
3108
+ # compute()/recompute() inline in expression
3109
+ # position, where C++'s &&/||/?: short-circuit
3110
+ # advances the series exactly on the bars Pine does.
3111
+ continue
2952
3112
  helper_binding_stack = variant.get("binding_stack", ())
2953
3113
  compute_args = self._security_ta_compute_args_for_site(
2954
3114
  sec_id,
@@ -459,45 +459,143 @@ ORDER_DIRECTION_MAP = {
459
459
  # ---------------------------------------------------------------------------
460
460
 
461
461
 
462
- def _checked_array_index_prelude(*, normalize_negative: bool = True) -> str:
462
+ def _checked_array_index_prelude(
463
+ *,
464
+ normalize_negative: bool = True,
465
+ allow_size: bool = False,
466
+ name: str = "index",
467
+ ) -> str:
463
468
  """C++ body shared by checked single-index operations.
464
469
 
465
- Pine v6 explicitly gives end-relative negative indices to the checked
466
- ``get``, ``set``, and ``remove`` cluster. Other indexed functions such as
467
- ``percentrank`` still need the same NA/non-finite/range checks, but must
468
- reject negative indices instead of normalizing them. ``insert`` remains a
469
- separately bounded residual.
470
+ Pine v6 explicitly gives end-relative negative indices to ``array.get``,
471
+ ``array.set``, ``array.insert``, and ``array.remove``. Other indexed
472
+ functions such as ``percentrank`` and the ``fill``/``slice`` range
473
+ endpoints still need the same NA/non-finite/range checks, but must reject
474
+ negative indices instead of normalizing them (``normalize_negative``).
475
+
476
+ ``allow_size`` widens the upper bound from ``index < size`` to
477
+ ``index <= size``. An *element access* is invalid at ``size``, but an
478
+ *insertion point* is not: ``array.insert(id, size, v)`` appends, and the
479
+ half-open ``[index_from, index_to)`` ranges of ``array.fill`` /
480
+ ``array.slice`` legally end at ``size``.
481
+
482
+ ``name`` renames every emitted local so two endpoints can be checked in
483
+ one C++ scope without colliding. ``name="index"`` reproduces the original
484
+ identifiers byte-for-byte.
470
485
  """
486
+ raw_value = f"__pf_raw_{name}_value"
487
+ raw_type = f"__pf_raw_{name}_type"
488
+ raw_text = f"__pf_raw_{name}_text"
489
+ raw_wide = f"__pf_raw_{name}_wide"
490
+ raw = f"__pf_raw_{name}"
491
+ checked = f"__pf_array_{name}"
492
+ size = "__pf_array_size" if name == "index" else f"__pf_array_size_{name}"
471
493
  checked_index = (
472
- "__pf_raw_index<0?__pf_raw_index+__pf_array_size:__pf_raw_index"
473
- if normalize_negative
474
- else "__pf_raw_index"
494
+ f"{raw}<0?{raw}+{size}:{raw}" if normalize_negative else raw
475
495
  )
496
+ upper = ">" if allow_size else ">="
476
497
  return (
477
- "using __pf_raw_index_type=std::decay_t<decltype(__pf_raw_index_value)>; "
478
- "if constexpr(!std::is_same_v<__pf_raw_index_type,bool>) { "
479
- "if(is_na(__pf_raw_index_value)) "
498
+ f"using {raw_type}=std::decay_t<decltype({raw_value})>; "
499
+ f"if constexpr(!std::is_same_v<{raw_type},bool>) {{ "
500
+ f"if(is_na({raw_value})) "
480
501
  "pine_runtime_error(std::string(\"Index na is out of bounds. Array size is \")+"
481
502
  "std::to_string((int64_t)__pf_array.size())); } "
482
- "if constexpr(std::is_floating_point_v<__pf_raw_index_type>) { "
483
- "if(!std::isfinite(__pf_raw_index_value)) { "
484
- "std::string __pf_raw_index_text=__pf_raw_index_value>0?\"inf\":\"-inf\"; "
485
- "pine_runtime_error(std::string(\"Index \")+__pf_raw_index_text+"
503
+ f"if constexpr(std::is_floating_point_v<{raw_type}>) {{ "
504
+ f"if(!std::isfinite({raw_value})) {{ "
505
+ f"std::string {raw_text}={raw_value}>0?\"inf\":\"-inf\"; "
506
+ f"pine_runtime_error(std::string(\"Index \")+{raw_text}+"
486
507
  "\" is out of bounds. Array size is \"+"
487
508
  "std::to_string((int64_t)__pf_array.size())); } "
488
- "long double __pf_raw_index_wide=(long double)__pf_raw_index_value; "
489
- "if(__pf_raw_index_wide<(long double)std::numeric_limits<int64_t>::min()||"
490
- "__pf_raw_index_wide>(long double)std::numeric_limits<int64_t>::max()) "
509
+ f"long double {raw_wide}=(long double){raw_value}; "
510
+ f"if({raw_wide}<(long double)std::numeric_limits<int64_t>::min()||"
511
+ f"{raw_wide}>(long double)std::numeric_limits<int64_t>::max()) "
491
512
  "pine_runtime_error(std::string(\"Index \")+"
492
- "std::to_string((double)__pf_raw_index_value)+"
513
+ f"std::to_string((double){raw_value})+"
493
514
  "\" is out of bounds. Array size is \"+"
494
515
  "std::to_string((int64_t)__pf_array.size())); } "
495
- "int64_t __pf_raw_index=(int64_t)__pf_raw_index_value; "
496
- "int64_t __pf_array_size=(int64_t)__pf_array.size(); "
497
- f"int64_t __pf_array_index={checked_index}; "
498
- "if(__pf_array_index<0||__pf_array_index>=__pf_array_size) "
499
- "pine_runtime_error(std::string(\"Index \")+std::to_string(__pf_raw_index)+"
500
- "\" is out of bounds. Array size is \"+std::to_string(__pf_array_size)); "
516
+ f"int64_t {raw}=(int64_t){raw_value}; "
517
+ f"int64_t {size}=(int64_t)__pf_array.size(); "
518
+ f"int64_t {checked}={checked_index}; "
519
+ f"if({checked}<0||{checked}{upper}{size}) "
520
+ f"pine_runtime_error(std::string(\"Index \")+std::to_string({raw})+"
521
+ f"\" is out of bounds. Array size is \"+std::to_string({size})); "
522
+ )
523
+
524
+
525
+ def _checked_array_range_prelude() -> str:
526
+ """Validate the half-open ``[index_from, index_to)`` range of fill/slice.
527
+
528
+ Both endpoints are checked with ``allow_size`` (``index_to`` is exclusive,
529
+ so ``size`` is a legal endpoint) and without negative normalization
530
+ (neither function appears in the Pine v6 reference's negative-indexing
531
+ set). An inverted range is rejected rather than silently treated as empty:
532
+ ``begin()+from > begin()+to`` is undefined behaviour in the STL forms this
533
+ replaces, and no evidence pins TradingView's behaviour there.
534
+ """
535
+ return (
536
+ _checked_array_index_prelude(
537
+ normalize_negative=False, allow_size=True, name="index_from"
538
+ )
539
+ + _checked_array_index_prelude(
540
+ normalize_negative=False, allow_size=True, name="index_to"
541
+ )
542
+ + "if(__pf_array_index_from>__pf_array_index_to) "
543
+ "pine_runtime_error(std::string(\"Index range \")+"
544
+ "std::to_string(__pf_array_index_from)+\"..\"+"
545
+ "std::to_string(__pf_array_index_to)+"
546
+ "\" is invalid. Array size is \"+"
547
+ "std::to_string(__pf_array_size_index_from)); "
548
+ )
549
+
550
+
551
+ def _checked_array_insert(a: str, args: list[str]) -> str:
552
+ """``array.insert(id, index, value)`` — index is an INSERTION point.
553
+
554
+ ``index == size`` appends, so the bound is ``index <= size``; a negative
555
+ index is end-relative, exactly as for ``get``/``set``/``remove``.
556
+ """
557
+ check = _checked_array_index_prelude(allow_size=True)
558
+ return (
559
+ "[&](auto&& __pf_array){ "
560
+ "return [&](auto&& __pf_raw_index_value){ "
561
+ "return [&](auto&& __pf_array_value){ "
562
+ f"{check}"
563
+ "__pf_array.insert(__pf_array.begin()+(size_t)__pf_array_index, "
564
+ "__pf_array_value); "
565
+ f"}}(({args[1]})); }}(({args[0]})); }}(({a}))"
566
+ )
567
+
568
+
569
+ def _checked_array_fill_range(a: str, args: list[str]) -> str:
570
+ """``array.fill(id, value, index_from, index_to)`` — bounded range fill."""
571
+ check = _checked_array_range_prelude()
572
+ return (
573
+ "[&](auto&& __pf_array){ "
574
+ "return [&](auto&& __pf_array_value){ "
575
+ "return [&](auto&& __pf_raw_index_from_value){ "
576
+ "return [&](auto&& __pf_raw_index_to_value){ "
577
+ f"{check}"
578
+ "std::fill(__pf_array.begin()+(size_t)__pf_array_index_from, "
579
+ "__pf_array.begin()+(size_t)__pf_array_index_to, __pf_array_value); "
580
+ f"}}(({args[2]})); }}(({args[1]})); }}(({args[0]})); }}(({a}))"
581
+ )
582
+
583
+
584
+ def checked_array_slice(a: str, args: list[str], *, result_type: str) -> str:
585
+ """``array.slice(id, index_from, index_to)`` — bounded range extraction.
586
+
587
+ ``result_type`` stays caller-supplied so the typed method lane keeps
588
+ emitting the receiver's own element type; only the bounds checks are new.
589
+ """
590
+ check = _checked_array_range_prelude()
591
+ return (
592
+ "[&](auto&& __pf_array){ "
593
+ "return [&](auto&& __pf_raw_index_from_value){ "
594
+ "return [&](auto&& __pf_raw_index_to_value){ "
595
+ f"{check}"
596
+ f"return {result_type}(__pf_array.begin()+(size_t)__pf_array_index_from, "
597
+ "__pf_array.begin()+(size_t)__pf_array_index_to); "
598
+ f"}}(({args[1]})); }}(({args[0]})); }}(({a}))"
501
599
  )
502
600
 
503
601
 
@@ -600,7 +698,7 @@ ARRAY_METHODS = {
600
698
  "set": _checked_array_set,
601
699
  "push": lambda a, args: f"{a}.push_back({args[0]})",
602
700
  "unshift": lambda a, args: f"{a}.insert({a}.begin(), {args[0]})",
603
- "insert": lambda a, args: f"{a}.insert({a}.begin() + (int)({args[0]}), {args[1]})",
701
+ "insert": _checked_array_insert,
604
702
  "pop": lambda a, args: _checked_array_end_remove(a, "pop"),
605
703
  "shift": lambda a, args: _checked_array_end_remove(a, "shift"),
606
704
  "remove": _checked_array_remove,
@@ -609,7 +707,7 @@ ARRAY_METHODS = {
609
707
  "size": lambda a, args: f"(double){a}.size()",
610
708
  "clear": lambda a, args: f"{a}.clear()",
611
709
  "fill": lambda a, args: f"std::fill({a}.begin(), {a}.end(), {args[0]})" if len(args) == 1
612
- else f"std::fill({a}.begin()+(int)({args[1]}), {a}.begin()+(int)({args[2]}), {args[0]})",
710
+ else _checked_array_fill_range(a, args),
613
711
  "includes": lambda a, args: f"(std::find({a}.begin(), {a}.end(), {args[0]}) != {a}.end())",
614
712
  "indexof": lambda a, args: f"[&](){{ auto it=std::find({a}.begin(),{a}.end(),{args[0]}); return it!={a}.end()?(double)(it-{a}.begin()):-1.0; }}()",
615
713
  "lastindexof": lambda a, args: f"[&](){{ for(int i=(int){a}.size()-1;i>=0;i--)if({a}[i]=={args[0]})return(double)i; return -1.0; }}()",
@@ -619,7 +717,7 @@ ARRAY_METHODS = {
619
717
  ),
620
718
  "reverse": lambda a, args: f"std::reverse({a}.begin(),{a}.end())",
621
719
  "copy": lambda a, args: f"std::vector<double>({a})",
622
- "slice": lambda a, args: f"std::vector<double>({a}.begin()+(int)({args[0]}),{a}.begin()+(int)({args[1]}))",
720
+ "slice": lambda a, args: checked_array_slice(a, args, result_type="std::vector<double>"),
623
721
  "concat": lambda a, args: f"{a}.insert({a}.end(),{args[0]}.begin(),{args[0]}.end())",
624
722
  "sum": lambda a, args: f"({a}.empty()?na<double>():std::accumulate({a}.begin(),{a}.end(),0.0))",
625
723
  "avg": lambda a, args: f"({a}.empty()?na<double>():std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size())",
@@ -667,6 +765,9 @@ CHECKED_ARRAY_METHOD_KWARGS: dict[str, list[str]] = {
667
765
  "set": ["index", "value"],
668
766
  "remove": ["index"],
669
767
  "percentrank": ["index"],
768
+ "insert": ["index", "value"],
769
+ "fill": ["value", "index_from", "index_to"],
770
+ "slice": ["index_from", "index_to"],
670
771
  "first": [],
671
772
  "last": [],
672
773
  "pop": [],
@@ -50,6 +50,7 @@ from .tables import (
50
50
  MATRIX_RETURNING_METHODS,
51
51
  PINE_TYPE_TO_CPP,
52
52
  TA_RETURNS_BOOL,
53
+ checked_array_slice,
53
54
  )
54
55
 
55
56
  # Collection (array / map / matrix) methods that MUTATE the receiver in place.
@@ -960,7 +961,11 @@ class TypeInferer:
960
961
  if method == "copy":
961
962
  lower_receiver = lambda recv: f"{arr_cpp_type}({recv})"
962
963
  elif method == "slice":
963
- lower_receiver = lambda recv: f"{arr_cpp_type}({recv}.begin()+(int)({args[0]}),{recv}.begin()+(int)({args[1]}))"
964
+ # Bounds-checked in the shared helper; the element type stays
965
+ # caller-supplied so the typed lane keeps its own vector type.
966
+ lower_receiver = lambda recv: checked_array_slice(
967
+ recv, args, result_type=arr_cpp_type
968
+ )
964
969
  elif method == "join" and elem_cpp == "std::string":
965
970
  sep = args[0] if args else 'std::string(",")'
966
971
  lower_receiver = lambda recv: f"[&](){{ std::string r; for(size_t i=0;i<{recv}.size();i++){{ if(i>0)r+={sep}; r+={recv}[i]; }} return r; }}()"
@@ -2145,25 +2145,25 @@ class CallVisitor:
2145
2145
  arg_idx,
2146
2146
  self._callable_target_callsite_idx(fi_lookup, node),
2147
2147
  )
2148
- if isinstance(arg_node, Identifier):
2148
+ chart_execution_context = (
2149
+ id(node) not in self._requested_context_inline_node_ids
2150
+ and (
2151
+ getattr(self, "_active_func_name", None),
2152
+ self._current_instance_name,
2153
+ ) not in self._requested_context_only_inline_contexts
2154
+ )
2155
+ if not chart_execution_context and isinstance(arg_node, Identifier):
2156
+ # request.security/request.security_lower_tf own a separate
2157
+ # evaluator clock and inline their UDF expressions in
2158
+ # security.py. Preserve the pre-factor direct-Series
2159
+ # lowering in the otherwise-emitted helper bodies.
2149
2160
  aname = arg_node.name
2150
- # Bar field: pass _s_close instead of current_bar_.close
2151
2161
  if (
2152
2162
  (aname in BAR_FIELDS or aname in BAR_SERIES_PUSH)
2153
2163
  and expected_cpp_type == "double"
2154
2164
  ):
2155
2165
  return f"_s_{aname}"
2156
- # Exact Series binding: pass the Series object directly.
2157
- # A raw name can also denote a scalar sibling/callable
2158
- # shadow, in which case lexical state must override the
2159
- # legacy ``ctx.series_vars`` union and fall through to the
2160
- # synthetic history bridge below.
2161
2166
  safe = self._safe_name(aname)
2162
- # Function parameters are lexical C++ arguments in every
2163
- # emitted variant. The legacy function-series clone table
2164
- # can contain the same raw name, but applying it here makes
2165
- # cs1+ ignore the actual parameter and read an unrelated
2166
- # class member instead.
2167
2167
  is_current_series_param = (
2168
2168
  aname in self._current_func_series_params
2169
2169
  )
@@ -2188,13 +2188,31 @@ class CallVisitor:
2188
2188
  expr_cpp, cpp_t
2189
2189
  )
2190
2190
  member = self._inline_history_member(
2191
- "series_arg", node, arg_idx=arg_idx
2191
+ (
2192
+ "udf_series_arg"
2193
+ if chart_execution_context
2194
+ else "series_arg"
2195
+ ),
2196
+ node,
2197
+ arg_idx=arg_idx,
2192
2198
  )
2199
+ if not chart_execution_context:
2200
+ return (
2201
+ f"([&]() -> const Series<{cpp_t}>& {{ "
2202
+ f"{cpp_t} _sv = {bridge_cpp}; "
2203
+ f"if (history_advances_new_bar()) {member}.push(_sv); "
2204
+ f"else {member}.update(_sv); "
2205
+ f"return {member}; }}())"
2206
+ )
2207
+ # A chart-executed plain UDF's history-reading parameter
2208
+ # belongs to this lexical call site, not to the caller Series
2209
+ # object. The on_bar preamble has already advanced its
2210
+ # synthetic buffer by one chart slot, so execution replaces
2211
+ # that slot rather than pushing a second one.
2193
2212
  return (
2194
2213
  f"([&]() -> const Series<{cpp_t}>& {{ "
2195
2214
  f"{cpp_t} _sv = {bridge_cpp}; "
2196
- f"if (history_advances_new_bar()) {member}.push(_sv); "
2197
- f"else {member}.update(_sv); "
2215
+ f"{member}.update(_sv); "
2198
2216
  f"return {member}; }}())"
2199
2217
  )
2200
2218
  # A concrete nullable collection specialization learned through an
@@ -516,11 +516,20 @@ class SupportChecker:
516
516
  # then ``request.security(haTicker, ...)``, or ``reqSym = cond ? other :
517
517
  # syminfo.tickerid``. First binding wins (closest to declaration).
518
518
  self._scalar_defs: dict[str, ASTNode] = {}
519
+ # EVERY reassignment value bound to a scalar name (``name := <expr>``
520
+ # and the compound forms). A declaration alone does not pin the value a
521
+ # ``request.security`` symbol argument actually carries: the engine is
522
+ # registered with no symbol at all, so an accepted-but-divergent rebind
523
+ # silently backtests the CHART feed. Collected in a whole-AST pre-pass
524
+ # (below) as well as during the visit, so a rebind that lexically
525
+ # FOLLOWS the request.security call cannot slip past.
526
+ self._scalar_rebinds: dict[str, list[ASTNode]] = {}
519
527
 
520
528
  # -- Public API --
521
529
 
522
530
  def check(self) -> list[Diagnostic]:
523
531
  self._collect_user_definitions(self._ast)
532
+ self._collect_scalar_rebinds(self._ast)
524
533
  for stmt in self._ast.body:
525
534
  self._visit(stmt)
526
535
  return self._diagnostics
@@ -533,6 +542,42 @@ class SupportChecker:
533
542
 
534
543
  # -- Setup --
535
544
 
545
+ def _record_scalar_rebind(self, node: Assignment) -> None:
546
+ """Remember ``name := <expr>`` (and the compound forms) by name.
547
+
548
+ A compound op (``+=``, ``*=``, …) records its right-hand operand, which
549
+ is never a current-symbol expression — so a string-built symbol is
550
+ rejected by the same rule that rejects a plain divergent ``:=``.
551
+ """
552
+ target = node.target
553
+ if not isinstance(target, Identifier) or node.value is None:
554
+ return
555
+ recorded = self._scalar_rebinds.setdefault(target.name, [])
556
+ if not any(value is node.value for value in recorded):
557
+ recorded.append(node.value)
558
+
559
+ def _collect_scalar_rebinds(self, node: ASTNode) -> None:
560
+ """Pre-pass: collect every scalar rebind anywhere in the AST.
561
+
562
+ The visit walks top-level statements in source order, so a rebind that
563
+ FOLLOWS a ``request.security`` call would otherwise never be seen by
564
+ that call's symbol check. Collecting up front is strictly fail-closed:
565
+ it can only add rejections, never remove one.
566
+ """
567
+ if isinstance(node, Assignment):
568
+ self._record_scalar_rebind(node)
569
+ for value in vars(node).values():
570
+ if isinstance(value, ASTNode):
571
+ self._collect_scalar_rebinds(value)
572
+ elif isinstance(value, list):
573
+ for item in value:
574
+ if isinstance(item, ASTNode):
575
+ self._collect_scalar_rebinds(item)
576
+ elif isinstance(value, dict):
577
+ for item in value.values():
578
+ if isinstance(item, ASTNode):
579
+ self._collect_scalar_rebinds(item)
580
+
536
581
  def _collect_user_definitions(self, ast: Program) -> None:
537
582
  for stmt in ast.body:
538
583
  if isinstance(stmt, TypeDecl):
@@ -1001,6 +1046,14 @@ class SupportChecker:
1001
1046
  )
1002
1047
  self._visit_children(node)
1003
1048
 
1049
+ def _visit_Assignment(self, node: Assignment) -> None:
1050
+ # ``_scalar_defs`` records only the DECLARATION, so a later ``:=``
1051
+ # rebind used to be invisible to the request.security symbol check.
1052
+ # (``check`` also pre-collects these; recording here keeps the visit
1053
+ # self-consistent for any caller that drives visitors directly.)
1054
+ self._record_scalar_rebind(node)
1055
+ self._visit_children(node)
1056
+
1004
1057
  def _visit_TupleAssign(self, node: TupleAssign) -> None:
1005
1058
  drawing_mask = self._tuple_assign_drawing_mask(node.value)
1006
1059
  if drawing_mask:
@@ -1723,10 +1776,21 @@ class SupportChecker:
1723
1776
  or self._is_current_symbol_expr(node.false_val, _seen))
1724
1777
  # Def-use: resolve a bare identifier through its declaration value so an
1725
1778
  # aliased symbol is accepted (``haTicker = ticker.heikinashi(...)`` then
1726
- # ``request.security(haTicker, ...)``). Name-cycle-guarded.
1727
- if isinstance(node, Identifier) and node.name in self._scalar_defs and node.name not in _seen:
1728
- _seen.add(node.name)
1729
- return self._is_current_symbol_expr(self._scalar_defs[node.name], _seen)
1779
+ # ``request.security(haTicker, ...)``). Every ``:=`` rebind of the name
1780
+ # must resolve to the chart symbol too: the declaration alone does not
1781
+ # pin the value the call actually receives. Name-cycle-guarded.
1782
+ if isinstance(node, Identifier) and node.name not in _seen:
1783
+ definition = self._scalar_defs.get(node.name)
1784
+ rebinds = self._scalar_rebinds.get(node.name)
1785
+ if definition is not None or rebinds:
1786
+ _seen.add(node.name)
1787
+ if rebinds and not all(
1788
+ self._is_current_symbol_expr(value, _seen) for value in rebinds
1789
+ ):
1790
+ return False
1791
+ if definition is None:
1792
+ return False
1793
+ return self._is_current_symbol_expr(definition, _seen)
1730
1794
  return False
1731
1795
 
1732
1796
  # -- Pine timeframe-literal validation --
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "0.10.1",
2
+ "codegen": "0.10.2",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "e0d1ec121b40e4be39c958e338900789768fb1e6ec39b99b4cb3301c80f13a89"
6
+ "sha256": "97ebb8d0b14c32d3c88b9521c8f13150ee9cb9e3859227af970ec4dcbfa6bf71"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "0.10.1",
2
+ "CODEGEN_VERSION": "0.10.2",
3
3
  "TA_CLASS_MAP_KEYS": [
4
4
  "sma",
5
5
  "ema",
Binary file