@pineforge/codegen-pyodide 0.10.3 → 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 (51) 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 +767 -77
  7. package/pineforge_codegen/analyzer/call_handlers.py +268 -42
  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 +907 -159
  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 +961 -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 +1198 -101
  25. package/pineforge_codegen/codegen/tables.py +205 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +2055 -62
  28. package/pineforge_codegen/codegen/visit_call.py +929 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +765 -63
  30. package/pineforge_codegen/codegen/visit_stmt.py +610 -52
  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/symbols.py +4 -2
  47. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  48. package/release.json +2 -2
  49. package/tables.json +23 -21
  50. package/transpile.worker.mjs +24 -16
  51. package/pineforge_codegen-0.10.3.tar.gz +0 -0
@@ -16,7 +16,33 @@ consumers should never reach for them directly.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import re
20
+
19
21
  from ..symbols import PineType
22
+ from .helpers import (
23
+ evaluate_args_once, na_preserving_int_cast, pine_index_int_cast,
24
+ pine_truth_cast,
25
+ )
26
+
27
+
28
+ _PROVEN_INT_ARG = re.compile(
29
+ r"^\s*(?:(?:[-+]?\d+)|get_input_int\(.*\)|pine_(?:bar_index|year|month|dayofmonth|dayofweek|hour|minute|second|weekofyear)\(.*\))\s*$"
30
+ )
31
+
32
+
33
+ def _matrix_int_arg(value: str) -> str:
34
+ """Keep old casts for typed integer matrix indices; guard other values."""
35
+ if _PROVEN_INT_ARG.fullmatch(value):
36
+ return f"(int)({value})"
37
+ return pine_index_int_cast(value)
38
+
39
+
40
+ def _pine_bool_arg(value: str) -> str:
41
+ """Keep literal bool spellings; guard numeric/dynamic bool arguments."""
42
+ stripped = value.strip()
43
+ if stripped in {"true", "false"}:
44
+ return value
45
+ return pine_truth_cast(value)
20
46
 
21
47
 
22
48
  # ---------------------------------------------------------------------------
@@ -75,6 +101,15 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:
75
101
  )
76
102
 
77
103
 
104
+ # ``alert.freq_*``: TradingView's ``const string`` values. A TradingView tape
105
+ # spells them as order comments: "all", "once_per_bar", "once_per_bar_close".
106
+ ALERT_FREQ_VALUES = {
107
+ "freq_all": "all",
108
+ "freq_once_per_bar": "once_per_bar",
109
+ "freq_once_per_bar_close": "once_per_bar_close",
110
+ }
111
+
112
+
78
113
  BAR_BUILTINS = {
79
114
  "bar_index": "pine_bar_index()",
80
115
  "time": "current_bar_.timestamp",
@@ -113,6 +148,7 @@ BAR_SERIES_PUSH = {
113
148
  "hl2": "((current_bar_.high + current_bar_.low) / 2.0)",
114
149
  "hlc3": "((current_bar_.high + current_bar_.low + current_bar_.close) / 3.0)",
115
150
  "ohlc4": "((current_bar_.open + current_bar_.high + current_bar_.low + current_bar_.close) / 4.0)",
151
+ "hlcc4": "((current_bar_.high + current_bar_.low + current_bar_.close + current_bar_.close) / 4.0)",
116
152
  }
117
153
 
118
154
  # Bar identifiers that refer to the *security* (HTF) bar inside
@@ -124,6 +160,12 @@ SECURITY_BAR_FIELD_EXPRS = {
124
160
  "close": "bar.close",
125
161
  "volume": "bar.volume",
126
162
  "time": "bar.timestamp",
163
+ # The derived series, as the engine computes its source series of a bar;
164
+ # their history is the requested bar's, like ``close[1]``'s.
165
+ "hl2": "((bar.high + bar.low) / 2.0)",
166
+ "hlc3": "((bar.high + bar.low + bar.close) / 3.0)",
167
+ "ohlc4": "((bar.open + bar.high + bar.low + bar.close) / 4.0)",
168
+ "hlcc4": "((bar.high + bar.low + bar.close + bar.close) / 4.0)",
127
169
  }
128
170
  SECURITY_BAR_FIELD_TYPES = {
129
171
  "open": "double",
@@ -132,8 +174,12 @@ SECURITY_BAR_FIELD_TYPES = {
132
174
  "close": "double",
133
175
  "volume": "double",
134
176
  "time": "int64_t",
177
+ "time_close": "int64_t",
135
178
  }
136
- SECURITY_BAR_FIELDS = frozenset(SECURITY_BAR_FIELD_EXPRS)
179
+ # ``time_close`` is the requested bar's close time, which depends on the
180
+ # requested timeframe: the evaluator renders it (security.py
181
+ # ``_security_bar_field_expr``) instead of a fixed ``bar.*`` read.
182
+ SECURITY_BAR_FIELDS = frozenset(SECURITY_BAR_FIELD_EXPRS) | {"time_close"}
137
183
 
138
184
  # Backwards-compatible name for consumers that only need the OHLCV subset.
139
185
  SECURITY_OHLC_BAR_FIELDS = frozenset({"open", "high", "low", "close", "volume"})
@@ -153,6 +199,24 @@ RUNTIME_REGISTER_SECURITY_EVAL_FN = "register_security_eval"
153
199
  # higher-or-equal TF requests with a precise diagnostic.
154
200
  RUNTIME_REGISTER_SECURITY_LOWER_TF_EVAL_FN = "register_security_lower_tf_eval"
155
201
 
202
+ # TradingView's Pine v6 ``strategy()`` defaults for the three parameters whose
203
+ # default it changed on 2026-09-24: an omitted ``initial_capital`` is 100000,
204
+ # an omitted ``default_qty_type`` is ``strategy.percent_of_equity``, and an
205
+ # omitted ``default_qty_value`` is 100 whatever the quantity type (100
206
+ # contracts under ``strategy.fixed``, 100 of the account currency under
207
+ # ``strategy.cash``). Pine v5 kept 1000000 / ``strategy.fixed`` / 1, which are
208
+ # still the source host's own ``PineStrategyConfig`` defaults, so the
209
+ # constructor declares these three whenever a script omits them (the analyzer
210
+ # admits Pine v6 only). Every other ``strategy()`` parameter the lane probed --
211
+ # currency, commission, slippage, margin, pyramiding, process_orders_on_close,
212
+ # close_entries_rule, use_bar_magnifier -- kept its default, which stays the
213
+ # host's. Pinned by TradingView tapes (``tests/fixtures/strategy_defaults``).
214
+ PINE_V6_STRATEGY_DEFAULTS = {
215
+ "initial_capital": 100000.0,
216
+ "default_qty_type": "strategy.percent_of_equity",
217
+ "default_qty_value": 100.0,
218
+ }
219
+
156
220
 
157
221
  # ---------------------------------------------------------------------------
158
222
  # TA dispatch tables
@@ -173,7 +237,7 @@ TA_COMPUTE_ARGS = {
173
237
  "stoch": [0, 1, 2],
174
238
  "highest": [0], "lowest": [0],
175
239
  "crossover": [0, 1], "crossunder": [0, 1], "cross": [0, 1],
176
- "change": [0],
240
+ "change": [0, 1], # source + length (the ctor only bounds the history)
177
241
  "supertrend": [], # supertrend gets bar data implicitly
178
242
  "dmi": [], # dmi gets bar data implicitly
179
243
  "bb": [0],
@@ -215,13 +279,26 @@ TA_COMPUTE_ARGS = {
215
279
  "wvad": [],
216
280
  "iii": [],
217
281
  "vwap": [0], # source explicit, volume appended
218
- # 3-arg bands form: only source (arg 0) goes to compute(); anchor (arg 1) is
219
- # the Pine series gate (not forwarded); stdev_mult (arg 2) went to the ctor.
282
+ # 3-arg bands form without an explicit anchor: source (arg 0) goes to
283
+ # compute(); stdev_mult (arg 2) goes to the ctor. Explicit anchors are
284
+ # remapped to the anchored shim by the analyzer.
220
285
  "vwap_bands": [0],
221
286
  "mode": [0], "range": [0], "dev": [0],
222
287
  "max": [0], "min": [0], "rci": [0],
223
288
  }
224
289
 
290
+ # issue #178: ta.atr / ta.tr read their true range against the previous
291
+ # CHART bar's close (close[1]) even when the call site executes sparsely —
292
+ # the RMA advances on the executions only (TradingView pin 2026-09-06,
293
+ # lab tv i178-sparse-atr-sense, BINANCE:BTCUSDT 60: 398/398 executions equal
294
+ # the chart-close model, 0/398 the previous-execution model). The chart
295
+ # context therefore passes ``BacktestEngine::prev_chart_close()`` as the
296
+ # 4th compute() argument (the engine's always-on tracker); the per-object
297
+ # prev_close (3-arg form) remains for request.security contexts, whose
298
+ # ``bar.`` rewrite has no chart-close tracker.
299
+ TA_CHART_PREV_CLOSE = {"atr", "tr"}
300
+ TA_CHART_PREV_CLOSE_ARG = "prev_chart_close()"
301
+
225
302
  # TA functions whose ``.compute()`` always receives bar OHLC implicitly.
226
303
  TA_IMPLICIT_COMPUTE_FULL = {
227
304
  "atr": "current_bar_.high, current_bar_.low, current_bar_.close",
@@ -270,6 +347,7 @@ TA_TUPLE_FIELDS = {
270
347
  "dmi": ["diplus", "diminus", "adx"],
271
348
  # ta.vwap 3-arg bands form → VWAPBandsResult {vwap, upper, lower}
272
349
  "vwap_bands": ["vwap", "upper", "lower"],
350
+ "vwap_anchored_bands": ["vwap", "upper", "lower"],
273
351
  }
274
352
 
275
353
 
@@ -294,7 +372,7 @@ PINE_TYPE_TO_CPP = {
294
372
  PineType.INT: "int", PineType.FLOAT: "double", PineType.BOOL: "bool",
295
373
  PineType.STRING: "std::string", PineType.NA: "double",
296
374
  PineType.UNKNOWN: "double", PineType.VOID: "double",
297
- PineType.COLOR: "int",
375
+ PineType.COLOR: "int64_t",
298
376
  # Drawing-objects-as-data: the value-view handle structs (see
299
377
  # drawing.hpp). Explicit-hint decls (``var line x``), UDT-method drawing
300
378
  # params, and ``_type_for_decl`` resolve through here.
@@ -367,7 +445,13 @@ SYMINFO_MEMBER_MAP = {
367
445
  "currency": "syminfo_.currency",
368
446
  "basecurrency": "syminfo_.basecurrency",
369
447
  "type": "syminfo_.type",
370
- "timezone": "syminfo_.timezone",
448
+ # TradingView spells a UTC exchange zone "Etc/UTC" (lab tv tapes
449
+ # te_syminfo_timezone on BINANCE:ETHUSDT.P 15 and BINANCE:BTCUSDT 1D:
450
+ # syminfo.timezone == "UTC" is false there); the engine names that zone
451
+ # "UTC", its SymInfo default. The Pine read spells it TradingView's way;
452
+ # the engine calls taking the symbol's zone keep reading syminfo_.timezone.
453
+ "timezone": ('(syminfo_.timezone.empty() || syminfo_.timezone == "UTC"'
454
+ ' ? std::string("Etc/UTC") : syminfo_.timezone)'),
371
455
  "session": "syminfo_.session",
372
456
  "volumetype": "syminfo_.volumetype",
373
457
  "description": "syminfo_.description",
@@ -376,8 +460,11 @@ SYMINFO_MEMBER_MAP = {
376
460
  "root": 'na<std::string>()',
377
461
  "pricescale": 'na<double>()',
378
462
  "minmove": 'na<double>()',
463
+ # --- Run facts: the value the run declares through the syminfo
464
+ # metadata channel (runtime_overrides.syminfo_metadata, a lane fact),
465
+ # na when it declares none. ---
466
+ "mincontract": 'get_syminfo_metadata("mincontract")',
379
467
  # --- External-data fields: na-accept so scripts compile ---
380
- "mincontract": 'na<double>()',
381
468
  "current_contract": 'na<std::string>()',
382
469
  "expiration_date": 'na<int64_t>()',
383
470
  "isin": 'na<std::string>()',
@@ -527,15 +614,14 @@ def _checked_array_index_prelude(
527
614
  )
528
615
 
529
616
 
530
- def _checked_array_range_prelude() -> str:
617
+ def _checked_array_range_prelude(*, reject_inverted: bool = True) -> str:
531
618
  """Validate the half-open ``[index_from, index_to)`` range of fill/slice.
532
619
 
533
620
  Both endpoints are checked with ``allow_size`` (``index_to`` is exclusive,
534
621
  so ``size`` is a legal endpoint) and without negative normalization
535
622
  (neither function appears in the Pine v6 reference's negative-indexing
536
- set). An inverted range is rejected rather than silently treated as empty:
537
- ``begin()+from > begin()+to`` is undefined behaviour in the STL forms this
538
- replaces, and no evidence pins TradingView's behaviour there.
623
+ set). TradingView treats an inverted ``fill`` range as empty, but raises
624
+ for an inverted ``slice`` range. The caller selects that distinction.
539
625
  """
540
626
  return (
541
627
  _checked_array_index_prelude(
@@ -544,22 +630,22 @@ def _checked_array_range_prelude() -> str:
544
630
  + _checked_array_index_prelude(
545
631
  normalize_negative=False, allow_size=True, name="index_to"
546
632
  )
547
- + "if(__pf_array_index_from>__pf_array_index_to) "
548
- "pine_runtime_error(std::string(\"Index range \")+"
549
- "std::to_string(__pf_array_index_from)+\"..\"+"
550
- "std::to_string(__pf_array_index_to)+"
551
- "\" is invalid. Array size is \"+"
552
- "std::to_string(__pf_array_size_index_from)); "
633
+ + (
634
+ "if(__pf_array_index_from>__pf_array_index_to) "
635
+ "pine_runtime_error(\"Index 'from' should be less than index 'to'.\"); "
636
+ if reject_inverted else ""
637
+ )
553
638
  )
554
639
 
555
640
 
556
- def _checked_array_insert(a: str, args: list[str]) -> str:
641
+ def _checked_array_insert(a: str, args: list[str], normalize_negative: bool = True) -> str:
557
642
  """``array.insert(id, index, value)`` — index is an INSERTION point.
558
643
 
559
644
  ``index == size`` appends, so the bound is ``index <= size``; a negative
560
645
  index is end-relative, exactly as for ``get``/``set``/``remove``.
561
646
  """
562
- check = _checked_array_index_prelude(allow_size=True)
647
+ check = _checked_array_index_prelude(allow_size=True,
648
+ normalize_negative=normalize_negative)
563
649
  return (
564
650
  "[&](auto&& __pf_array){ "
565
651
  "return [&](auto&& __pf_raw_index_value){ "
@@ -573,13 +659,14 @@ def _checked_array_insert(a: str, args: list[str]) -> str:
573
659
 
574
660
  def _checked_array_fill_range(a: str, args: list[str]) -> str:
575
661
  """``array.fill(id, value, index_from, index_to)`` — bounded range fill."""
576
- check = _checked_array_range_prelude()
662
+ check = _checked_array_range_prelude(reject_inverted=False)
577
663
  return (
578
664
  "[&](auto&& __pf_array){ "
579
665
  "return [&](auto&& __pf_array_value){ "
580
666
  "return [&](auto&& __pf_raw_index_from_value){ "
581
667
  "return [&](auto&& __pf_raw_index_to_value){ "
582
668
  f"{check}"
669
+ "if(__pf_array_index_from<__pf_array_index_to) "
583
670
  "std::fill(__pf_array.begin()+(size_t)__pf_array_index_from, "
584
671
  "__pf_array.begin()+(size_t)__pf_array_index_to, __pf_array_value); "
585
672
  f"}}(({args[2]})); }}(({args[1]})); }}(({args[0]})); }}(({a}))"
@@ -604,8 +691,8 @@ def checked_array_slice(a: str, args: list[str], *, result_type: str) -> str:
604
691
  )
605
692
 
606
693
 
607
- def _checked_array_get(a: str, args: list[str]) -> str:
608
- check = _checked_array_index_prelude()
694
+ def _checked_array_get(a: str, args: list[str], normalize_negative: bool = True) -> str:
695
+ check = _checked_array_index_prelude(normalize_negative=normalize_negative)
609
696
  return (
610
697
  "[&](auto&& __pf_array)->decltype(auto){ "
611
698
  "return [&](auto&& __pf_raw_index_value)->decltype(auto){ "
@@ -619,8 +706,8 @@ def _checked_array_get(a: str, args: list[str]) -> str:
619
706
  )
620
707
 
621
708
 
622
- def _checked_array_set(a: str, args: list[str]) -> str:
623
- check = _checked_array_index_prelude()
709
+ def _checked_array_set(a: str, args: list[str], normalize_negative: bool = True) -> str:
710
+ check = _checked_array_index_prelude(normalize_negative=normalize_negative)
624
711
  return (
625
712
  "[&](auto&& __pf_array){ "
626
713
  "return [&](auto&& __pf_raw_index_value){ "
@@ -631,8 +718,8 @@ def _checked_array_set(a: str, args: list[str]) -> str:
631
718
  )
632
719
 
633
720
 
634
- def _checked_array_remove(a: str, args: list[str]) -> str:
635
- check = _checked_array_index_prelude()
721
+ def _checked_array_remove(a: str, args: list[str], normalize_negative: bool = True) -> str:
722
+ check = _checked_array_index_prelude(normalize_negative=normalize_negative)
636
723
  return (
637
724
  "[&](auto&& __pf_array){ "
638
725
  "return [&](auto&& __pf_raw_index_value){ "
@@ -697,6 +784,17 @@ def _checked_array_percentrank(a: str, args: list[str]) -> str:
697
784
  )
698
785
 
699
786
 
787
+ # Pine v5 raises a runtime error on a negative index to these (TradingView:
788
+ # "Index -1 is out of bounds"); v6 counts from the array's end. The v5 forms
789
+ # lower a v5 library's body (``library_v5``).
790
+ V5_ARRAY_INDEX_METHODS = {
791
+ "get": lambda a, args: _checked_array_get(a, args, normalize_negative=False),
792
+ "set": lambda a, args: _checked_array_set(a, args, normalize_negative=False),
793
+ "insert": lambda a, args: _checked_array_insert(a, args, normalize_negative=False),
794
+ "remove": lambda a, args: _checked_array_remove(a, args, normalize_negative=False),
795
+ }
796
+
797
+
700
798
  # Methods called as ``array.method(arr, ...)`` or ``arr.method(...)``.
701
799
  ARRAY_METHODS = {
702
800
  "get": _checked_array_get,
@@ -714,8 +812,8 @@ ARRAY_METHODS = {
714
812
  "fill": lambda a, args: f"std::fill({a}.begin(), {a}.end(), {args[0]})" if len(args) == 1
715
813
  else _checked_array_fill_range(a, args),
716
814
  "includes": lambda a, args: f"(std::find({a}.begin(), {a}.end(), {args[0]}) != {a}.end())",
717
- "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; }}()",
718
- "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; }}()",
815
+ "indexof": lambda a, args: f"[&](){{ auto __pf_it=std::find({a}.begin(),{a}.end(),{args[0]}); return __pf_it!={a}.end()?(double)(__pf_it-{a}.begin()):-1.0; }}()",
816
+ "lastindexof": lambda a, args: f"[&](){{ for(int __pf_i=(int){a}.size()-1;__pf_i>=0;__pf_i--)if({a}[__pf_i]=={args[0]})return(double)__pf_i; return -1.0; }}()",
719
817
  "sort": lambda a, args: (
720
818
  f"[&](){{ if (({args[0]}) == \"descending\") std::sort({a}.begin(), {a}.end(), std::greater<>()); else std::sort({a}.begin(), {a}.end()); }}()"
721
819
  if args else f"std::sort({a}.begin(), {a}.end())"
@@ -729,37 +827,50 @@ ARRAY_METHODS = {
729
827
  "min": lambda a, args: f"({a}.empty()?na<double>():*std::min_element({a}.begin(),{a}.end()))",
730
828
  "max": lambda a, args: f"({a}.empty()?na<double>():*std::max_element({a}.begin(),{a}.end()))",
731
829
  "range": lambda a, args: f"({a}.empty()?na<double>():*std::max_element({a}.begin(),{a}.end())-*std::min_element({a}.begin(),{a}.end()))",
732
- "every": lambda a, args: f"std::all_of({a}.begin(),{a}.end(),[](double v){{return v!=0.0;}})",
733
- "some": lambda a, args: f"std::any_of({a}.begin(),{a}.end(),[](double v){{return v!=0.0;}})",
830
+ "every": lambda a, args: f"std::all_of({a}.begin(),{a}.end(),[](auto _pf_raw){{ using _pf_val_t=typename std::decay_t<decltype({a})>::value_type; _pf_val_t v=_pf_raw; if constexpr(std::is_same_v<_pf_val_t,bool>) return v; else return !is_na(v) && v!=0; }})",
831
+ "some": lambda a, args: f"std::any_of({a}.begin(),{a}.end(),[](auto _pf_raw){{ using _pf_val_t=typename std::decay_t<decltype({a})>::value_type; _pf_val_t v=_pf_raw; if constexpr(std::is_same_v<_pf_val_t,bool>) return v; else return !is_na(v) && v!=0; }})",
734
832
  # stdev/variance honor the optional 2nd ``biased`` arg (Pine v6:
735
833
  # biased=true → population (default), false → sample / n-1).
736
834
  "stdev": lambda a, args: (
737
- f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); "
738
- f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
739
- f"return _d>0?std::sqrt(s/_d):na<double>(); }}()"
835
+ f"[&](){{ if({a}.empty()) return na<double>(); double __pf_m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double __pf_s=0; for(auto v:{a})__pf_s+=(v-__pf_m)*(v-__pf_m); "
836
+ f"double __pf_d=({_pine_bool_arg(args[0])})?(double){a}.size():((double){a}.size()-1.0); "
837
+ f"return __pf_d>0?std::sqrt(__pf_s/__pf_d):na<double>(); }}()"
740
838
  if args else
741
- f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return std::sqrt(s/{a}.size()); }}()"
839
+ f"[&](){{ if({a}.empty()) return na<double>(); double __pf_m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double __pf_s=0; for(auto v:{a})__pf_s+=(v-__pf_m)*(v-__pf_m); return std::sqrt(__pf_s/{a}.size()); }}()"
742
840
  ),
743
841
  "variance": lambda a, args: (
744
- f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); "
745
- f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
746
- f"return _d>0?s/_d:na<double>(); }}()"
842
+ f"[&](){{ if({a}.empty()) return na<double>(); double __pf_m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double __pf_s=0; for(auto v:{a})__pf_s+=(v-__pf_m)*(v-__pf_m); "
843
+ f"double __pf_d=({_pine_bool_arg(args[0])})?(double){a}.size():((double){a}.size()-1.0); "
844
+ f"return __pf_d>0?__pf_s/__pf_d:na<double>(); }}()"
747
845
  if args else
748
- f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return s/{a}.size(); }}()"
846
+ f"[&](){{ if({a}.empty()) return na<double>(); double __pf_m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double __pf_s=0; for(auto v:{a})__pf_s+=(v-__pf_m)*(v-__pf_m); return __pf_s/{a}.size(); }}()"
749
847
  ),
750
- "median": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); auto c={a}; std::sort(c.begin(),c.end()); int n=c.size(); return n%2?c[n/2]:(c[n/2-1]+c[n/2])/2.0; }}()",
751
- "mode": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); std::unordered_map<double,int> m; for(auto v:{a})m[v]++; double best=0; int bc=0; for(auto&[v,c]:m)if(c>bc||(c==bc&&v<best)){{bc=c;best=v;}} return best; }}()",
752
- "percentile_linear_interpolation": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); auto c={a}; std::sort(c.begin(),c.end()); double k=({args[0]}/100.0)*c.size()-0.5; int i=std::max(0,(int)k); double f=k-i; if(i+1>=(int)c.size()) return c.back(); return c[i]*(1-f)+c[i+1]*f; }}()",
753
- "percentile_nearest_rank": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); auto c={a}; std::sort(c.begin(),c.end()); int r=(int)std::ceil(({args[0]}/100.0)*c.size()); return (double)c[std::min(r-1,(int)c.size()-1)]; }}()",
848
+ "median": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); auto __pf_c={a}; std::sort(__pf_c.begin(),__pf_c.end()); int __pf_n=__pf_c.size(); return __pf_n%2?__pf_c[__pf_n/2]:(__pf_c[__pf_n/2-1]+__pf_c[__pf_n/2])/2.0; }}()",
849
+ "mode": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); std::unordered_map<double,int> __pf_m; for(auto v:{a})__pf_m[v]++; double __pf_best=0; int __pf_bc=0; for(auto&[v,c]:__pf_m)if(c>__pf_bc||(c==__pf_bc&&v<__pf_best)){{__pf_bc=c;__pf_best=v;}} return __pf_best; }}()",
850
+ "percentile_linear_interpolation": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); auto __pf_c={a}; std::sort(__pf_c.begin(),__pf_c.end()); double __pf_k=({args[0]}/100.0)*__pf_c.size()-0.5; if(!std::isfinite(__pf_k)) return na<double>(); if(__pf_k>=__pf_c.size()-1.0) return (double)__pf_c.back(); int __pf_i=__pf_k<=0.0?0:(int)__pf_k; double __pf_f=__pf_k-__pf_i; return __pf_c[__pf_i]*(1-__pf_f)+__pf_c[__pf_i+1]*__pf_f; }}()",
851
+ "percentile_nearest_rank": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); auto __pf_c={a}; std::sort(__pf_c.begin(),__pf_c.end()); double __pf_p=({args[0]}); if(is_na(__pf_p)) return (double)__pf_c.front(); double __pf_rank=std::ceil((__pf_p/100.0)*__pf_c.size()); if(!std::isfinite(__pf_rank)) return na<double>(); int __pf_r=(int)std::clamp(__pf_rank,1.0,(double)__pf_c.size()); return (double)__pf_c[__pf_r-1]; }}()",
754
852
  "percentrank": _checked_array_percentrank,
755
- "abs": lambda a, args: f"[&](){{ std::vector<double> r; for(auto v:{a})r.push_back(std::abs(v)); return r; }}()",
756
- "join": lambda a, args: "[&](){{ std::string r; for(size_t i=0;i<{arr}.size();i++){{ if(i>0)r+={sep}; r+=std::to_string({arr}[i]); }} return r; }}()".format(arr=a, sep=args[0] if args else 'std::string(",")'),
757
- "standardize": lambda a, args: f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); s=std::sqrt(s/{a}.size()); std::vector<double> r; for(auto v:{a})r.push_back(s==0?1.0:(v-m)/s); return r; }}()",
758
- "covariance": lambda a, args: f"[&](){{ auto&&b=({args[0]}); int n=std::min({a}.size(),b.size()); if(n<=0) return na<double>(); double ma=0,mb=0; for(int i=0;i<n;i++){{ma+={a}[i];mb+=b[i];}} ma/=n;mb/=n; double c=0; for(int i=0;i<n;i++)c+=({a}[i]-ma)*(b[i]-mb); return c/n; }}()",
759
- "binary_search": lambda a, args: f"[&](){{ auto it=std::lower_bound({a}.begin(),{a}.end(),{args[0]}); return (it!={a}.end()&&*it=={args[0]})?(double)(it-{a}.begin()):-1.0; }}()",
760
- "binary_search_leftmost": lambda a, args: f"[&](){{ auto it=std::lower_bound({a}.begin(),{a}.end(),{args[0]}); return (it!={a}.end()&&*it=={args[0]})?(double)(it-{a}.begin()):(double)(it-{a}.begin()-1); }}()",
761
- "binary_search_rightmost": lambda a, args: f"[&](){{ auto it=std::upper_bound({a}.begin(),{a}.end(),{args[0]}); return (it!={a}.begin()&&*(it-1)=={args[0]})?(double)(it-{a}.begin()-1):(double)(it-{a}.begin()); }}()",
762
- "sort_indices": lambda a, args: f"[&](){{ std::vector<double> idx({a}.size()); std::iota(idx.begin(),idx.end(),0); std::sort(idx.begin(),idx.end(),[&](int i,int j){{return {a}[i]<{a}[j];}}); return idx; }}()",
853
+ "abs": lambda a, args: f"[&](){{ std::vector<double> __pf_r; for(auto v:{a})__pf_r.push_back(std::abs(v)); return __pf_r; }}()",
854
+ "join": lambda a, args: "[&](){{ std::string __pf_r; for(size_t __pf_i=0;__pf_i<{arr}.size();__pf_i++){{ if(__pf_i>0)__pf_r+={sep}; __pf_r+=std::to_string({arr}[__pf_i]); }} return __pf_r; }}()".format(arr=a, sep=args[0] if args else 'std::string(",")'),
855
+ "standardize": lambda a, args: f"[&](){{ double __pf_m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double __pf_s=0; for(auto v:{a})__pf_s+=(v-__pf_m)*(v-__pf_m); __pf_s=std::sqrt(__pf_s/{a}.size()); std::vector<double> __pf_r; for(auto v:{a})__pf_r.push_back(__pf_s==0?1.0:(v-__pf_m)/__pf_s); return __pf_r; }}()",
856
+ "covariance": lambda a, args: f"[&](){{ auto&&__pf_b=({args[0]}); int __pf_n=std::min({a}.size(),__pf_b.size()); if(__pf_n<=0) return na<double>(); double __pf_ma=0,__pf_mb=0; for(int __pf_i=0;__pf_i<__pf_n;__pf_i++){{__pf_ma+={a}[__pf_i];__pf_mb+=__pf_b[__pf_i];}} __pf_ma/=__pf_n;__pf_mb/=__pf_n; double __pf_c=0; for(int __pf_i=0;__pf_i<__pf_n;__pf_i++)__pf_c+=({a}[__pf_i]-__pf_ma)*(__pf_b[__pf_i]-__pf_mb); return __pf_c/__pf_n; }}()",
857
+ "binary_search": lambda a, args: f"[&](){{ auto __pf_it=std::lower_bound({a}.begin(),{a}.end(),{args[0]}); return (__pf_it!={a}.end()&&*__pf_it=={args[0]})?(double)(__pf_it-{a}.begin()):-1.0; }}()",
858
+ "binary_search_leftmost": lambda a, args: f"[&](){{ auto __pf_it=std::lower_bound({a}.begin(),{a}.end(),{args[0]}); return (__pf_it!={a}.end()&&*__pf_it=={args[0]})?(double)(__pf_it-{a}.begin()):(double)(__pf_it-{a}.begin()-1); }}()",
859
+ "binary_search_rightmost": lambda a, args: f"[&](){{ auto __pf_it=std::upper_bound({a}.begin(),{a}.end(),{args[0]}); return (__pf_it!={a}.begin()&&*(__pf_it-1)=={args[0]})?(double)(__pf_it-{a}.begin()-1):(double)(__pf_it-{a}.begin()); }}()",
860
+ "sort_indices": lambda a, args: f"[&](){{ std::vector<double> __pf_idx({a}.size()); std::iota(__pf_idx.begin(),__pf_idx.end(),0); std::sort(__pf_idx.begin(),__pf_idx.end(),[&](int __pf_i,int __pf_j){{return {a}[__pf_i]<{a}[__pf_j];}}); return __pf_idx; }}()",
861
+ }
862
+
863
+ # The ARRAY_METHODS arguments (receiver excluded) a template reads more than
864
+ # once, or once per loop iteration: evaluate_args_once binds each that is not
865
+ # a plain read. The typed string ``join`` lowering reads its separator the
866
+ # same way.
867
+ ARRAY_ARGS_READ_REPEATEDLY = {
868
+ "lastindexof": (0,),
869
+ "binary_search": (0,),
870
+ "binary_search_leftmost": (0,),
871
+ "binary_search_rightmost": (0,),
872
+ "join": (0,),
873
+ "concat": (0,),
763
874
  }
764
875
 
765
876
  # Pine parameter order for the checked-index subset. Keeping the receiver
@@ -818,7 +929,7 @@ def _matrix_add_row(m: str, args: list) -> str:
818
929
  if len(args) == 1:
819
930
  return f"{m}.add_row((int)({m}.rows()), {args[0]})"
820
931
  if len(args) == 2:
821
- return f"{m}.add_row((int)({args[0]}), {args[1]})"
932
+ return f"{m}.add_row({_matrix_int_arg(args[0])}, {args[1]})"
822
933
  raise IndexError("matrix.add_row")
823
934
 
824
935
 
@@ -827,7 +938,7 @@ def _matrix_add_col(m: str, args: list) -> str:
827
938
  if len(args) == 1:
828
939
  return f"{m}.add_col((int)({m}.columns()), {args[0]})"
829
940
  if len(args) == 2:
830
- return f"{m}.add_col((int)({args[0]}), {args[1]})"
941
+ return f"{m}.add_col({_matrix_int_arg(args[0])}, {args[1]})"
831
942
  raise IndexError("matrix.add_col")
832
943
 
833
944
 
@@ -885,28 +996,28 @@ MATRIX_NUMERIC_ONLY: frozenset[str] = frozenset({
885
996
  MATRIX_SORT_ALLOWED_GENERIC_ELEMS: frozenset[str] = frozenset({"int", "bool", "string"})
886
997
 
887
998
  MATRIX_METHODS = {
888
- "get": lambda m, args: f"{m}.get((int)({args[0]}), (int)({args[1]}))",
889
- "set": lambda m, args: f"{m}.set((int)({args[0]}), (int)({args[1]}), {args[2]})",
999
+ "get": lambda m, args: f"{m}.get({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])})",
1000
+ "set": lambda m, args: f"{m}.set({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])}, {args[2]})",
890
1001
  "fill": lambda m, args: f"{m}.fill({args[0]})",
891
- "row": lambda m, args: f"{m}.row((int)({args[0]}))",
892
- "col": lambda m, args: f"{m}.col((int)({args[0]}))",
1002
+ "row": lambda m, args: f"{m}.row({_matrix_int_arg(args[0])})",
1003
+ "col": lambda m, args: f"{m}.col({_matrix_int_arg(args[0])})",
893
1004
  "rows": lambda m, args: f"(int){m}.rows()",
894
1005
  "columns": lambda m, args: f"(int){m}.columns()",
895
1006
  "add_row": _matrix_add_row,
896
1007
  "add_col": _matrix_add_col,
897
1008
  # ``remove_row`` is void in C++; Pine may assign the result, so we wrap
898
1009
  # it in a lambda that returns a sentinel double after the side effect.
899
- "remove_row": lambda m, args: f"[&](){{ {m}.remove_row((int)({args[0]})); return 0.0; }}()",
900
- "remove_col":lambda m, args: f"[&](){{ {m}.remove_col((int)({args[0]})); return 0.0; }}()",
901
- "swap_rows": lambda m, args: f"{m}.swap_rows((int)({args[0]}), (int)({args[1]}))",
902
- "swap_columns": lambda m, args: f"{m}.swap_columns((int)({args[0]}), (int)({args[1]}))",
1010
+ "remove_row": lambda m, args: f"[&](){{ {m}.remove_row({_matrix_int_arg(args[0])}); return 0.0; }}()",
1011
+ "remove_col":lambda m, args: f"[&](){{ {m}.remove_col({_matrix_int_arg(args[0])}); return 0.0; }}()",
1012
+ "swap_rows": lambda m, args: f"{m}.swap_rows({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])})",
1013
+ "swap_columns": lambda m, args: f"{m}.swap_columns({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])})",
903
1014
  "copy": lambda m, args: f"{m}.copy()",
904
- "submatrix": lambda m, args: f"{m}.submatrix((int)({args[0]}), (int)({args[1]}), (int)({args[2]}), (int)({args[3]}))",
905
- "reshape": lambda m, args: f"{m}.reshape((int)({args[0]}), (int)({args[1]}))",
1015
+ "submatrix": lambda m, args: f"{m}.submatrix({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])}, {_matrix_int_arg(args[2])}, {_matrix_int_arg(args[3])})",
1016
+ "reshape": lambda m, args: f"{m}.reshape({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])})",
906
1017
  "reverse": lambda m, args: f"{m}.reverse()",
907
1018
  "transpose": lambda m, args: f"{m}.transpose()",
908
- "sort": lambda m, args: f"{m}.sort((int)({args[0]}), {args[1]} != \"descending\")" if len(args)>1 else f"{m}.sort((int)({args[0]}))",
909
- "concat": lambda m, args: f"{m}.concat({args[0]}, (bool)({args[1]}))" if len(args)>1 else f"{m}.concat({args[0]}, true)",
1019
+ "sort": lambda m, args: f"{m}.sort({_matrix_int_arg(args[0])}, {args[1]} != \"descending\")" if len(args)>1 else f"{m}.sort({_matrix_int_arg(args[0])})",
1020
+ "concat": lambda m, args: f"{m}.concat({args[0]}, {pine_truth_cast(args[1])})" if len(args)>1 else f"{m}.concat({args[0]}, true)",
910
1021
  "avg": lambda m, args: f"{m}.avg()",
911
1022
  "min": lambda m, args: f"{m}.min()",
912
1023
  "max": lambda m, args: f"{m}.max()",
@@ -914,7 +1025,7 @@ MATRIX_METHODS = {
914
1025
  "sum": lambda m, args: f"{m}.sum()",
915
1026
  "diff": lambda m, args: f"{m}.diff({args[0]})",
916
1027
  "mult": lambda m, args: f"{m}.mult({args[0]})",
917
- "pow": lambda m, args: f"{m}.pow((int)({args[0]}))",
1028
+ "pow": lambda m, args: f"{m}.pow({_matrix_int_arg(args[0])})",
918
1029
  "det": lambda m, args: f"{m}.det()",
919
1030
  "inv": lambda m, args: f"{m}.inv()",
920
1031
  "pinv": lambda m, args: f"{m}.pinv()",
@@ -968,6 +1079,17 @@ def _math_minmax_na_expr(func_name: str, args: list[str]) -> str:
968
1079
  )
969
1080
 
970
1081
 
1082
+ def _math_round_digits_expr(args: list[str]) -> str:
1083
+ """``math.round(number, precision)``: the template reads the precision
1084
+ twice, so one that is more than a plain read is evaluated once."""
1085
+ return evaluate_args_once(
1086
+ args, (1,),
1087
+ lambda a: (f"(std::round({a[0]} * std::pow(10.0, {a[1]})) / "
1088
+ f"std::pow(10.0, {a[1]}))"),
1089
+ "_pf_round_a",
1090
+ )
1091
+
1092
+
971
1093
  # Pine ``math.sign`` returns a float, propagates numeric ``na``, and must not
972
1094
  # evaluate its argument more than once. Keep the callable separate from the
973
1095
  # call template because the stable-runtime TA reset fallback also substitutes
@@ -1003,18 +1125,30 @@ STR_FUNC_MAP = {
1003
1125
  "contains": lambda args: f"({args[0]}.find({args[1]}) != std::string::npos)",
1004
1126
  "startswith": lambda args: f"({args[0]}.substr(0, {args[1]}.length()) == {args[1]})",
1005
1127
  "endswith": lambda args: f"({args[0]}.length() >= {args[1]}.length() && {args[0]}.compare({args[0]}.length() - {args[1]}.length(), {args[1]}.length(), {args[1]}) == 0)",
1006
- "pos": lambda args: f"[&](){{ auto p={args[0]}.find({args[1]}); return p!=std::string::npos?(int)p:-1; }}()",
1128
+ "pos": lambda args: f"[&](){{ auto __pf_p={args[0]}.find({args[1]}); return __pf_p!=std::string::npos?(int)__pf_p:-1; }}()",
1007
1129
  "substring": None, # handled separately (2 vs 3 args)
1008
- "replace_all": lambda args: f"[&](){{ std::string s={args[0]}; size_t p=0; while((p=s.find({args[1]},p))!=std::string::npos){{ s.replace(p,{args[1]}.length(),{args[2]}); p+={args[2]}.length(); }} return s; }}()",
1130
+ "replace_all": lambda args: f"[&](){{ std::string __pf_s={args[0]}; size_t __pf_p=0; while((__pf_p=__pf_s.find({args[1]},__pf_p))!=std::string::npos){{ __pf_s.replace(__pf_p,{args[1]}.length(),{args[2]}); __pf_p+={args[2]}.length(); }} return __pf_s; }}()",
1009
1131
  "replace": None, # handled separately (3 vs 4 args)
1010
- "lower": lambda args: f"[&](){{ std::string s={args[0]}; std::transform(s.begin(),s.end(),s.begin(),::tolower); return s; }}()",
1011
- "upper": lambda args: f"[&](){{ std::string s={args[0]}; std::transform(s.begin(),s.end(),s.begin(),::toupper); return s; }}()",
1012
- "trim": lambda args: f'[&](){{ std::string s={args[0]}; s.erase(0,s.find_first_not_of(" \\t\\n\\r")); s.erase(s.find_last_not_of(" \\t\\n\\r")+1); return s; }}()',
1013
- "repeat": lambda args: f"[&](){{ std::string r; for(int i=0;i<(int)({args[1]});i++) r+={args[0]}; return r; }}()",
1132
+ "lower": lambda args: f"[&](){{ std::string __pf_s={args[0]}; std::transform(__pf_s.begin(),__pf_s.end(),__pf_s.begin(),::tolower); return __pf_s; }}()",
1133
+ "upper": lambda args: f"[&](){{ std::string __pf_s={args[0]}; std::transform(__pf_s.begin(),__pf_s.end(),__pf_s.begin(),::toupper); return __pf_s; }}()",
1134
+ "trim": lambda args: f'[&](){{ std::string __pf_s={args[0]}; __pf_s.erase(0,__pf_s.find_first_not_of(" \\t\\n\\r")); __pf_s.erase(__pf_s.find_last_not_of(" \\t\\n\\r")+1); return __pf_s; }}()',
1135
+ "repeat": lambda args: (
1136
+ f"[&](){{ std::string __pf_r; for(int __pf_i=0;__pf_i<{na_preserving_int_cast(args[1])};__pf_i++) __pf_r+={args[0]}; return __pf_r; }}()"
1137
+ if len(args) < 3 else
1138
+ f"[&](){{ std::string __pf_r; for(int __pf_i=0;__pf_i<{na_preserving_int_cast(args[1])};__pf_i++){{ if(__pf_i>0) __pf_r+={args[2]}; __pf_r+={args[0]}; }} return __pf_r; }}()"
1139
+ ),
1014
1140
  "match": lambda args: f'pine_str_match({args[0]}, {args[1]})',
1015
1141
  "split": lambda args: f'pine_str_split({args[0]}, {args[1]})',
1016
1142
  "format": None, # handled separately
1017
1143
  }
1144
+ # The STR_FUNC_MAP arguments a template reads more than once, or once per
1145
+ # loop iteration: evaluate_args_once binds each that is not a plain read.
1146
+ STR_ARGS_READ_REPEATEDLY = {
1147
+ "startswith": (1,),
1148
+ "endswith": (0, 1),
1149
+ "replace_all": (1, 2),
1150
+ "repeat": (0, 1, 2),
1151
+ }
1018
1152
 
1019
1153
 
1020
1154
  # ---------------------------------------------------------------------------