@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
@@ -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,8 +279,9 @@ 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],
@@ -282,6 +347,7 @@ TA_TUPLE_FIELDS = {
282
347
  "dmi": ["diplus", "diminus", "adx"],
283
348
  # ta.vwap 3-arg bands form → VWAPBandsResult {vwap, upper, lower}
284
349
  "vwap_bands": ["vwap", "upper", "lower"],
350
+ "vwap_anchored_bands": ["vwap", "upper", "lower"],
285
351
  }
286
352
 
287
353
 
@@ -306,7 +372,7 @@ PINE_TYPE_TO_CPP = {
306
372
  PineType.INT: "int", PineType.FLOAT: "double", PineType.BOOL: "bool",
307
373
  PineType.STRING: "std::string", PineType.NA: "double",
308
374
  PineType.UNKNOWN: "double", PineType.VOID: "double",
309
- PineType.COLOR: "int",
375
+ PineType.COLOR: "int64_t",
310
376
  # Drawing-objects-as-data: the value-view handle structs (see
311
377
  # drawing.hpp). Explicit-hint decls (``var line x``), UDT-method drawing
312
378
  # params, and ``_type_for_decl`` resolve through here.
@@ -379,7 +445,13 @@ SYMINFO_MEMBER_MAP = {
379
445
  "currency": "syminfo_.currency",
380
446
  "basecurrency": "syminfo_.basecurrency",
381
447
  "type": "syminfo_.type",
382
- "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)'),
383
455
  "session": "syminfo_.session",
384
456
  "volumetype": "syminfo_.volumetype",
385
457
  "description": "syminfo_.description",
@@ -388,8 +460,11 @@ SYMINFO_MEMBER_MAP = {
388
460
  "root": 'na<std::string>()',
389
461
  "pricescale": 'na<double>()',
390
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")',
391
467
  # --- External-data fields: na-accept so scripts compile ---
392
- "mincontract": 'na<double>()',
393
468
  "current_contract": 'na<std::string>()',
394
469
  "expiration_date": 'na<int64_t>()',
395
470
  "isin": 'na<std::string>()',
@@ -539,15 +614,14 @@ def _checked_array_index_prelude(
539
614
  )
540
615
 
541
616
 
542
- def _checked_array_range_prelude() -> str:
617
+ def _checked_array_range_prelude(*, reject_inverted: bool = True) -> str:
543
618
  """Validate the half-open ``[index_from, index_to)`` range of fill/slice.
544
619
 
545
620
  Both endpoints are checked with ``allow_size`` (``index_to`` is exclusive,
546
621
  so ``size`` is a legal endpoint) and without negative normalization
547
622
  (neither function appears in the Pine v6 reference's negative-indexing
548
- set). An inverted range is rejected rather than silently treated as empty:
549
- ``begin()+from > begin()+to`` is undefined behaviour in the STL forms this
550
- 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.
551
625
  """
552
626
  return (
553
627
  _checked_array_index_prelude(
@@ -556,22 +630,22 @@ def _checked_array_range_prelude() -> str:
556
630
  + _checked_array_index_prelude(
557
631
  normalize_negative=False, allow_size=True, name="index_to"
558
632
  )
559
- + "if(__pf_array_index_from>__pf_array_index_to) "
560
- "pine_runtime_error(std::string(\"Index range \")+"
561
- "std::to_string(__pf_array_index_from)+\"..\"+"
562
- "std::to_string(__pf_array_index_to)+"
563
- "\" is invalid. Array size is \"+"
564
- "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
+ )
565
638
  )
566
639
 
567
640
 
568
- 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:
569
642
  """``array.insert(id, index, value)`` — index is an INSERTION point.
570
643
 
571
644
  ``index == size`` appends, so the bound is ``index <= size``; a negative
572
645
  index is end-relative, exactly as for ``get``/``set``/``remove``.
573
646
  """
574
- check = _checked_array_index_prelude(allow_size=True)
647
+ check = _checked_array_index_prelude(allow_size=True,
648
+ normalize_negative=normalize_negative)
575
649
  return (
576
650
  "[&](auto&& __pf_array){ "
577
651
  "return [&](auto&& __pf_raw_index_value){ "
@@ -585,13 +659,14 @@ def _checked_array_insert(a: str, args: list[str]) -> str:
585
659
 
586
660
  def _checked_array_fill_range(a: str, args: list[str]) -> str:
587
661
  """``array.fill(id, value, index_from, index_to)`` — bounded range fill."""
588
- check = _checked_array_range_prelude()
662
+ check = _checked_array_range_prelude(reject_inverted=False)
589
663
  return (
590
664
  "[&](auto&& __pf_array){ "
591
665
  "return [&](auto&& __pf_array_value){ "
592
666
  "return [&](auto&& __pf_raw_index_from_value){ "
593
667
  "return [&](auto&& __pf_raw_index_to_value){ "
594
668
  f"{check}"
669
+ "if(__pf_array_index_from<__pf_array_index_to) "
595
670
  "std::fill(__pf_array.begin()+(size_t)__pf_array_index_from, "
596
671
  "__pf_array.begin()+(size_t)__pf_array_index_to, __pf_array_value); "
597
672
  f"}}(({args[2]})); }}(({args[1]})); }}(({args[0]})); }}(({a}))"
@@ -616,8 +691,8 @@ def checked_array_slice(a: str, args: list[str], *, result_type: str) -> str:
616
691
  )
617
692
 
618
693
 
619
- def _checked_array_get(a: str, args: list[str]) -> str:
620
- 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)
621
696
  return (
622
697
  "[&](auto&& __pf_array)->decltype(auto){ "
623
698
  "return [&](auto&& __pf_raw_index_value)->decltype(auto){ "
@@ -631,8 +706,8 @@ def _checked_array_get(a: str, args: list[str]) -> str:
631
706
  )
632
707
 
633
708
 
634
- def _checked_array_set(a: str, args: list[str]) -> str:
635
- 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)
636
711
  return (
637
712
  "[&](auto&& __pf_array){ "
638
713
  "return [&](auto&& __pf_raw_index_value){ "
@@ -643,8 +718,8 @@ def _checked_array_set(a: str, args: list[str]) -> str:
643
718
  )
644
719
 
645
720
 
646
- def _checked_array_remove(a: str, args: list[str]) -> str:
647
- 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)
648
723
  return (
649
724
  "[&](auto&& __pf_array){ "
650
725
  "return [&](auto&& __pf_raw_index_value){ "
@@ -709,6 +784,17 @@ def _checked_array_percentrank(a: str, args: list[str]) -> str:
709
784
  )
710
785
 
711
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
+
712
798
  # Methods called as ``array.method(arr, ...)`` or ``arr.method(...)``.
713
799
  ARRAY_METHODS = {
714
800
  "get": _checked_array_get,
@@ -726,8 +812,8 @@ ARRAY_METHODS = {
726
812
  "fill": lambda a, args: f"std::fill({a}.begin(), {a}.end(), {args[0]})" if len(args) == 1
727
813
  else _checked_array_fill_range(a, args),
728
814
  "includes": lambda a, args: f"(std::find({a}.begin(), {a}.end(), {args[0]}) != {a}.end())",
729
- "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; }}()",
730
- "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; }}()",
731
817
  "sort": lambda a, args: (
732
818
  f"[&](){{ if (({args[0]}) == \"descending\") std::sort({a}.begin(), {a}.end(), std::greater<>()); else std::sort({a}.begin(), {a}.end()); }}()"
733
819
  if args else f"std::sort({a}.begin(), {a}.end())"
@@ -741,37 +827,50 @@ ARRAY_METHODS = {
741
827
  "min": lambda a, args: f"({a}.empty()?na<double>():*std::min_element({a}.begin(),{a}.end()))",
742
828
  "max": lambda a, args: f"({a}.empty()?na<double>():*std::max_element({a}.begin(),{a}.end()))",
743
829
  "range": lambda a, args: f"({a}.empty()?na<double>():*std::max_element({a}.begin(),{a}.end())-*std::min_element({a}.begin(),{a}.end()))",
744
- "every": lambda a, args: f"std::all_of({a}.begin(),{a}.end(),[](double v){{return v!=0.0;}})",
745
- "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; }})",
746
832
  # stdev/variance honor the optional 2nd ``biased`` arg (Pine v6:
747
833
  # biased=true → population (default), false → sample / n-1).
748
834
  "stdev": lambda a, args: (
749
- 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); "
750
- f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
751
- 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>(); }}()"
752
838
  if args else
753
- 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()); }}()"
754
840
  ),
755
841
  "variance": lambda a, args: (
756
- 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); "
757
- f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
758
- 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>(); }}()"
759
845
  if args else
760
- 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(); }}()"
761
847
  ),
762
- "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; }}()",
763
- "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; }}()",
764
- "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; }}()",
765
- "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]; }}()",
766
852
  "percentrank": _checked_array_percentrank,
767
- "abs": lambda a, args: f"[&](){{ std::vector<double> r; for(auto v:{a})r.push_back(std::abs(v)); return r; }}()",
768
- "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(",")'),
769
- "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; }}()",
770
- "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; }}()",
771
- "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; }}()",
772
- "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); }}()",
773
- "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()); }}()",
774
- "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,),
775
874
  }
776
875
 
777
876
  # Pine parameter order for the checked-index subset. Keeping the receiver
@@ -830,7 +929,7 @@ def _matrix_add_row(m: str, args: list) -> str:
830
929
  if len(args) == 1:
831
930
  return f"{m}.add_row((int)({m}.rows()), {args[0]})"
832
931
  if len(args) == 2:
833
- return f"{m}.add_row((int)({args[0]}), {args[1]})"
932
+ return f"{m}.add_row({_matrix_int_arg(args[0])}, {args[1]})"
834
933
  raise IndexError("matrix.add_row")
835
934
 
836
935
 
@@ -839,7 +938,7 @@ def _matrix_add_col(m: str, args: list) -> str:
839
938
  if len(args) == 1:
840
939
  return f"{m}.add_col((int)({m}.columns()), {args[0]})"
841
940
  if len(args) == 2:
842
- return f"{m}.add_col((int)({args[0]}), {args[1]})"
941
+ return f"{m}.add_col({_matrix_int_arg(args[0])}, {args[1]})"
843
942
  raise IndexError("matrix.add_col")
844
943
 
845
944
 
@@ -897,28 +996,28 @@ MATRIX_NUMERIC_ONLY: frozenset[str] = frozenset({
897
996
  MATRIX_SORT_ALLOWED_GENERIC_ELEMS: frozenset[str] = frozenset({"int", "bool", "string"})
898
997
 
899
998
  MATRIX_METHODS = {
900
- "get": lambda m, args: f"{m}.get((int)({args[0]}), (int)({args[1]}))",
901
- "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]})",
902
1001
  "fill": lambda m, args: f"{m}.fill({args[0]})",
903
- "row": lambda m, args: f"{m}.row((int)({args[0]}))",
904
- "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])})",
905
1004
  "rows": lambda m, args: f"(int){m}.rows()",
906
1005
  "columns": lambda m, args: f"(int){m}.columns()",
907
1006
  "add_row": _matrix_add_row,
908
1007
  "add_col": _matrix_add_col,
909
1008
  # ``remove_row`` is void in C++; Pine may assign the result, so we wrap
910
1009
  # it in a lambda that returns a sentinel double after the side effect.
911
- "remove_row": lambda m, args: f"[&](){{ {m}.remove_row((int)({args[0]})); return 0.0; }}()",
912
- "remove_col":lambda m, args: f"[&](){{ {m}.remove_col((int)({args[0]})); return 0.0; }}()",
913
- "swap_rows": lambda m, args: f"{m}.swap_rows((int)({args[0]}), (int)({args[1]}))",
914
- "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])})",
915
1014
  "copy": lambda m, args: f"{m}.copy()",
916
- "submatrix": lambda m, args: f"{m}.submatrix((int)({args[0]}), (int)({args[1]}), (int)({args[2]}), (int)({args[3]}))",
917
- "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])})",
918
1017
  "reverse": lambda m, args: f"{m}.reverse()",
919
1018
  "transpose": lambda m, args: f"{m}.transpose()",
920
- "sort": lambda m, args: f"{m}.sort((int)({args[0]}), {args[1]} != \"descending\")" if len(args)>1 else f"{m}.sort((int)({args[0]}))",
921
- "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)",
922
1021
  "avg": lambda m, args: f"{m}.avg()",
923
1022
  "min": lambda m, args: f"{m}.min()",
924
1023
  "max": lambda m, args: f"{m}.max()",
@@ -926,7 +1025,7 @@ MATRIX_METHODS = {
926
1025
  "sum": lambda m, args: f"{m}.sum()",
927
1026
  "diff": lambda m, args: f"{m}.diff({args[0]})",
928
1027
  "mult": lambda m, args: f"{m}.mult({args[0]})",
929
- "pow": lambda m, args: f"{m}.pow((int)({args[0]}))",
1028
+ "pow": lambda m, args: f"{m}.pow({_matrix_int_arg(args[0])})",
930
1029
  "det": lambda m, args: f"{m}.det()",
931
1030
  "inv": lambda m, args: f"{m}.inv()",
932
1031
  "pinv": lambda m, args: f"{m}.pinv()",
@@ -980,6 +1079,17 @@ def _math_minmax_na_expr(func_name: str, args: list[str]) -> str:
980
1079
  )
981
1080
 
982
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
+
983
1093
  # Pine ``math.sign`` returns a float, propagates numeric ``na``, and must not
984
1094
  # evaluate its argument more than once. Keep the callable separate from the
985
1095
  # call template because the stable-runtime TA reset fallback also substitutes
@@ -1015,18 +1125,30 @@ STR_FUNC_MAP = {
1015
1125
  "contains": lambda args: f"({args[0]}.find({args[1]}) != std::string::npos)",
1016
1126
  "startswith": lambda args: f"({args[0]}.substr(0, {args[1]}.length()) == {args[1]})",
1017
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)",
1018
- "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; }}()",
1019
1129
  "substring": None, # handled separately (2 vs 3 args)
1020
- "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; }}()",
1021
1131
  "replace": None, # handled separately (3 vs 4 args)
1022
- "lower": lambda args: f"[&](){{ std::string s={args[0]}; std::transform(s.begin(),s.end(),s.begin(),::tolower); return s; }}()",
1023
- "upper": lambda args: f"[&](){{ std::string s={args[0]}; std::transform(s.begin(),s.end(),s.begin(),::toupper); return s; }}()",
1024
- "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; }}()',
1025
- "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
+ ),
1026
1140
  "match": lambda args: f'pine_str_match({args[0]}, {args[1]})',
1027
1141
  "split": lambda args: f'pine_str_split({args[0]}, {args[1]})',
1028
1142
  "format": None, # handled separately
1029
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
+ }
1030
1152
 
1031
1153
 
1032
1154
  # ---------------------------------------------------------------------------