@pineforge/codegen-pyodide 0.8.1 → 0.9.0

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.
@@ -28,7 +28,9 @@ from __future__ import annotations
28
28
  from typing import TYPE_CHECKING
29
29
 
30
30
  from ..ast_nodes import (
31
- Assignment, BinOp, ExprStmt, FuncCall, Ternary, UnaryOp, VarDecl,
31
+ Assignment, BinOp, BoolLiteral, ColorLiteral, ExprStmt, FuncCall,
32
+ Identifier, MemberAccess, NaLiteral, NumberLiteral, StringLiteral,
33
+ Subscript, Ternary, TupleLiteral, UnaryOp, VarDecl,
32
34
  )
33
35
  from .tables import TA_IMPLICIT_APPEND, TA_IMPLICIT_COMPUTE_FULL
34
36
 
@@ -221,6 +223,90 @@ class TaSiteHelper:
221
223
 
222
224
  return ""
223
225
 
226
+ # ------------------------------------------------------------------
227
+ # Precalculation safety
228
+ # ------------------------------------------------------------------
229
+
230
+ _PRECALC_BAR_IDENTIFIERS = {
231
+ "open", "high", "low", "close", "volume",
232
+ "hl2", "hlc3", "ohlc4", "hlcc4",
233
+ "time", "time_close", "bar_index",
234
+ }
235
+
236
+ def _is_precalc_replayed_source_var(self, name: str) -> bool:
237
+ """True for top-level ``x = input.source(...)`` variables replayed in
238
+ ``precalculate()``.
239
+
240
+ The precompute loop explicitly advances native source series and then
241
+ replays those source-input assignments before computing static TA
242
+ sites. Other user aliases, even when they are statically derived from
243
+ bar data (``src = close`` / ``ha_close = close``), are not replayed
244
+ there and must therefore use the normal per-bar TA path."""
245
+ ast = getattr(self.ctx, "ast", None)
246
+ for stmt in getattr(ast, "body", ()):
247
+ if (
248
+ isinstance(stmt, VarDecl)
249
+ and stmt.name == name
250
+ and isinstance(stmt.value, FuncCall)
251
+ and self._is_source_input(stmt.value)
252
+ ):
253
+ return True
254
+ return False
255
+
256
+ def _expr_safe_for_ta_precalc(self, expr) -> bool:
257
+ if expr is None:
258
+ return True
259
+ if isinstance(expr, (NumberLiteral, StringLiteral, BoolLiteral, NaLiteral, ColorLiteral)):
260
+ return True
261
+ if isinstance(expr, Identifier):
262
+ if expr.name in self._PRECALC_BAR_IDENTIFIERS:
263
+ return True
264
+ if self._is_precalc_replayed_source_var(expr.name):
265
+ return True
266
+ if expr.name in getattr(self.ctx, "series_vars", set()):
267
+ return False
268
+ return expr.name in getattr(self, "_static_vars", set())
269
+ if isinstance(expr, MemberAccess):
270
+ if isinstance(expr.object, Identifier) and (
271
+ expr.object.name.startswith("input") or expr.object.name in getattr(self, "_enum_defs", {})
272
+ ):
273
+ return True
274
+ return self._expr_safe_for_ta_precalc(expr.object)
275
+ if isinstance(expr, BinOp):
276
+ return self._expr_safe_for_ta_precalc(expr.left) and self._expr_safe_for_ta_precalc(expr.right)
277
+ if isinstance(expr, UnaryOp):
278
+ return self._expr_safe_for_ta_precalc(expr.operand)
279
+ if isinstance(expr, Ternary):
280
+ return (
281
+ self._expr_safe_for_ta_precalc(expr.condition)
282
+ and self._expr_safe_for_ta_precalc(expr.true_val)
283
+ and self._expr_safe_for_ta_precalc(expr.false_val)
284
+ )
285
+ if isinstance(expr, Subscript):
286
+ return self._expr_safe_for_ta_precalc(expr.object) and self._expr_safe_for_ta_precalc(expr.index)
287
+ if isinstance(expr, TupleLiteral):
288
+ return all(self._expr_safe_for_ta_precalc(elem) for elem in expr.elements)
289
+ if isinstance(expr, FuncCall):
290
+ if isinstance(expr.callee, MemberAccess) and isinstance(expr.callee.object, Identifier):
291
+ if expr.callee.object.name in ("math", "str", "color"):
292
+ return all(self._expr_safe_for_ta_precalc(arg) for arg in expr.args)
293
+ return False
294
+ return False
295
+
296
+ def _ta_site_uses_precalc(self, site: "TACallSite") -> bool:
297
+ """Whether a static TA site can safely read from ``_precalc_*``.
298
+
299
+ Static-ness from the analyzer means the expression can be represented
300
+ from bar data and constants, but the standalone precompute loop only
301
+ replays a narrow subset of per-bar assignments. A user alias such as
302
+ ``ha_close = close`` is static in that analyzer sense, yet its Series is
303
+ empty during precompute, so ``ta.stdev(ha_close, 20)`` precalculates as
304
+ all-``na``. Opting that site out preserves correctness; it simply uses
305
+ the ordinary stateful TA object during ``on_bar``."""
306
+ if not getattr(site, "is_static", False):
307
+ return False
308
+ return all(self._expr_safe_for_ta_precalc(arg) for arg in site.compute_args)
309
+
224
310
  def _security_ta_compute_args_for_site(
225
311
  self,
226
312
  sec_id: int,
@@ -55,7 +55,7 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:
55
55
  """
56
56
  return (
57
57
  "[&]() -> int { "
58
- f"std::string _tz = ({tz_arg}); "
58
+ f"std::string _tz = pineforge::normalize_timezone_for_posix(({tz_arg})); "
59
59
  f"time_t _secs = (time_t)(({ts_arg}) / 1000); "
60
60
  "struct tm tm_buf; "
61
61
  "if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") { "
@@ -76,11 +76,11 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:
76
76
 
77
77
 
78
78
  BAR_BUILTINS = {
79
- "bar_index": "bar_index_",
79
+ "bar_index": "pine_bar_index()",
80
80
  "time": "current_bar_.timestamp",
81
81
  "time_close": "time_close()",
82
82
  "timenow": "current_bar_.timestamp",
83
- "last_bar_index": "last_bar_index_",
83
+ "last_bar_index": "pine_last_bar_index()",
84
84
  "last_bar_time": "last_bar_time_",
85
85
  # time_tradingday: Unix-ms of the session-open of the trading day that
86
86
  # contains the current bar. Backed by pine_time_tradingday() in the engine.
@@ -96,10 +96,12 @@ BAR_BUILTINS = {
96
96
  # ``_bar_hour()`` helpers. For UTC-exchange data (the crypto corpus,
97
97
  # SymInfo's constructor default) the lambda takes the gmtime_r fast
98
98
  # path and is value-identical to the old emission.
99
+ # Route through the engine's cached pine_hour/pine_minute/... (session_time.hpp)
100
+ # instead of an inline per-call setenv+tzset lambda — the lambda's TZ churn
101
+ # caused a macOS tzset()->notifyd IPC storm (KI-35). The engine helpers are
102
+ # value-identical (same tm fields + Pine offsets) but tzset-churn-free.
99
103
  **{
100
- name: tz_time_field_lambda(
101
- TIME_FIELD_EXPRS[name], "current_bar_.timestamp", "syminfo_.timezone"
102
- )
104
+ name: f"pine_{name}(current_bar_.timestamp, syminfo_.timezone)"
103
105
  for name in ("hour", "minute", "second", "dayofmonth", "dayofweek",
104
106
  "month", "year", "weekofyear")
105
107
  },
@@ -113,7 +115,27 @@ BAR_SERIES_PUSH = {
113
115
  "ohlc4": "((current_bar_.open + current_bar_.high + current_bar_.low + current_bar_.close) / 4.0)",
114
116
  }
115
117
 
116
- # OHLCV identifiers that refer to the *security* (HTF) bar inside ``request.security()``.
118
+ # Bar identifiers that refer to the *security* (HTF) bar inside
119
+ # ``request.security()``. ``time`` is the HTF bar-open timestamp.
120
+ SECURITY_BAR_FIELD_EXPRS = {
121
+ "open": "bar.open",
122
+ "high": "bar.high",
123
+ "low": "bar.low",
124
+ "close": "bar.close",
125
+ "volume": "bar.volume",
126
+ "time": "bar.timestamp",
127
+ }
128
+ SECURITY_BAR_FIELD_TYPES = {
129
+ "open": "double",
130
+ "high": "double",
131
+ "low": "double",
132
+ "close": "double",
133
+ "volume": "double",
134
+ "time": "int64_t",
135
+ }
136
+ SECURITY_BAR_FIELDS = frozenset(SECURITY_BAR_FIELD_EXPRS)
137
+
138
+ # Backwards-compatible name for consumers that only need the OHLCV subset.
117
139
  SECURITY_OHLC_BAR_FIELDS = frozenset({"open", "high", "low", "close", "volume"})
118
140
 
119
141
  # Generated C++ runtime function names referenced by the codegen as string
@@ -634,6 +656,33 @@ MATRIX_METHODS = {
634
656
  # Math / String dispatch
635
657
  # ---------------------------------------------------------------------------
636
658
 
659
+ def _math_minmax_na_expr(func_name: str, args: list[str]) -> str:
660
+ """Emit Pine-compatible math.min/math.max with na propagation.
661
+
662
+ ``std::min``/``std::max`` do not propagate NaN consistently because their
663
+ comparison is specified in terms of ``operator<``. Pine math.min/max return
664
+ ``na`` when any operand is ``na``, so generated clamp-style expressions
665
+ like ``math.max(-1, math.min(1, na))`` must stay ``na``.
666
+ """
667
+ if not args:
668
+ return "na<double>()"
669
+ if len(args) == 1:
670
+ return f"(double)({args[0]})"
671
+ op = "std::max" if func_name == "max" else "std::min"
672
+ decls = " ".join(
673
+ f"double _v{i} = (double)({arg});" for i, arg in enumerate(args)
674
+ )
675
+ guard = " || ".join(f"is_na(_v{i})" for i in range(len(args)))
676
+ updates = " ".join(
677
+ f"_out = {op}(_out, _v{i});" for i in range(1, len(args))
678
+ )
679
+ return (
680
+ f"([&]() -> double {{ {decls} "
681
+ f"if ({guard}) return na<double>(); "
682
+ f"double _out = _v0; {updates} return _out; }}())"
683
+ )
684
+
685
+
637
686
  MATH_FUNC_MAP = {
638
687
  "abs": "std::abs", "max": "std::max", "min": "std::min",
639
688
  "ceil": "std::ceil", "floor": "std::floor", "round": "std::round",
@@ -210,8 +210,14 @@ class TypeInferer:
210
210
  if isinstance(node, StringLiteral):
211
211
  return TypeSpec.primitive("string")
212
212
  if isinstance(node, Identifier):
213
+ loop_specs = getattr(self, "_current_loop_var_specs", None)
214
+ if loop_specs and node.name in loop_specs:
215
+ return loop_specs[node.name]
213
216
  if node.name in self._collection_types:
214
217
  return self._collection_types[node.name]
218
+ param_specs = getattr(self, "_current_func_param_specs", {})
219
+ if node.name in param_specs:
220
+ return param_specs[node.name]
215
221
  if node.name in self._udt_var_types:
216
222
  return TypeSpec.udt(self._udt_var_types[node.name])
217
223
  # Drawing-typed method/function parameter (L.6d / U.5): a ``line ln``
@@ -323,6 +329,44 @@ class TypeInferer:
323
329
  # Method lowering for collection types (used by visit_call paths)
324
330
  # ------------------------------------------------------------------
325
331
 
332
+ def _array_receiver_once_expr(
333
+ self, array_expr: str, args: list[str], lower_receiver,
334
+ ) -> str:
335
+ """Lower an array method without duplicating its receiver evaluation.
336
+
337
+ ``ARRAY_METHODS`` is intentionally a compact table of expression
338
+ templates. Many templates need the receiver more than once (for
339
+ example ``begin()`` + ``end()``). Substituting a temporary-producing
340
+ receiver directly into those slots creates distinct objects, so the
341
+ resulting iterator range is invalid. Render first with a fresh token;
342
+ when the template uses it repeatedly and the real receiver is not a
343
+ plain identifier lvalue, bind it to one lambda-local forwarding
344
+ reference and render every use through that binding. The lambda's
345
+ deduced ``auto`` return copies scalar results before a temporary
346
+ receiver dies; mutations still reach lvalue arrays through the
347
+ forwarding reference. Single-use and plain-identifier lowerings
348
+ remain byte-for-byte unchanged.
349
+
350
+ This is receiver-only by design: Pine argument evaluation and the
351
+ separate empty-array semantics are outside this fix.
352
+ """
353
+ counter = getattr(self, "_array_receiver_counter", 0)
354
+ occupied = "\n".join((array_expr, *args))
355
+ while True:
356
+ receiver = f"__pf_array_receiver_{counter}"
357
+ counter += 1
358
+ if receiver not in occupied:
359
+ break
360
+ self._array_receiver_counter = counter
361
+
362
+ lowered = lower_receiver(receiver)
363
+ if lowered.count(receiver) <= 1 or array_expr.isidentifier():
364
+ return lower_receiver(array_expr)
365
+ return (
366
+ f"[&]() {{ auto&& {receiver} = ({array_expr}); "
367
+ f"return {lowered}; }}()"
368
+ )
369
+
326
370
  def _array_method_expr(
327
371
  self, array_expr: str, method: str, args: list[str], spec: TypeSpec | None = None,
328
372
  ) -> str:
@@ -331,34 +375,36 @@ class TypeInferer:
331
375
  arr_cpp_type = self._type_spec_to_cpp(spec)
332
376
  elem_cpp = self._type_spec_to_cpp(spec.element) if spec.element is not None else "double"
333
377
  if method == "copy":
334
- return f"{arr_cpp_type}({array_expr})"
335
- if method == "slice":
336
- return f"{arr_cpp_type}({array_expr}.begin()+(int)({args[0]}),{array_expr}.begin()+(int)({args[1]}))"
337
- if method == "join":
378
+ lower_receiver = lambda recv: f"{arr_cpp_type}({recv})"
379
+ elif method == "slice":
380
+ lower_receiver = lambda recv: f"{arr_cpp_type}({recv}.begin()+(int)({args[0]}),{recv}.begin()+(int)({args[1]}))"
381
+ elif method == "join" and elem_cpp == "std::string":
338
382
  sep = args[0] if args else 'std::string(",")'
339
- if elem_cpp == "std::string":
340
- return f"[&](){{ std::string r; for(size_t i=0;i<{array_expr}.size();i++){{ if(i>0)r+={sep}; r+={array_expr}[i]; }} return r; }}()"
341
- numeric_only = {
342
- "sum", "avg", "min", "max", "range", "stdev", "variance", "median",
343
- "mode", "percentile_linear_interpolation", "percentile_nearest_rank",
344
- "percentrank", "abs", "standardize", "covariance", "binary_search",
345
- "binary_search_leftmost", "binary_search_rightmost", "sort_indices",
346
- }
347
- if method in numeric_only and elem_cpp not in ("double", "int"):
348
- self._codegen_error(
349
- None,
350
- f"array.{method} requires a numeric array",
351
- hint="Use numeric arrays for aggregate/statistical array functions.",
352
- )
353
- if method in ARRAY_METHODS:
354
- return ARRAY_METHODS[method](array_expr, args)
355
- # Defensive: support_checker rejects any array.* method not in
356
- # SUPPORTED_ARRAY (derived from ARRAY_METHODS). Reaching here means the
357
- # checker was bypassed or the tables drifted.
358
- raise ValueError(
359
- f"codegen: unhandled array method '{method}' — analyzer should have "
360
- f"rejected. Add it to ARRAY_METHODS."
361
- )
383
+ 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; }}()"
384
+ else:
385
+ numeric_only = {
386
+ "sum", "avg", "min", "max", "range", "stdev", "variance", "median",
387
+ "mode", "percentile_linear_interpolation", "percentile_nearest_rank",
388
+ "percentrank", "abs", "standardize", "covariance", "binary_search",
389
+ "binary_search_leftmost", "binary_search_rightmost", "sort_indices",
390
+ }
391
+ if method in numeric_only and elem_cpp not in ("double", "int"):
392
+ self._codegen_error(
393
+ None,
394
+ f"array.{method} requires a numeric array",
395
+ hint="Use numeric arrays for aggregate/statistical array functions.",
396
+ )
397
+ if method not in ARRAY_METHODS:
398
+ # Defensive: support_checker rejects any array.* method not in
399
+ # SUPPORTED_ARRAY (derived from ARRAY_METHODS). Reaching here means the
400
+ # checker was bypassed or the tables drifted.
401
+ raise ValueError(
402
+ f"codegen: unhandled array method '{method}' — analyzer should have "
403
+ f"rejected. Add it to ARRAY_METHODS."
404
+ )
405
+ lower_receiver = lambda recv: ARRAY_METHODS[method](recv, args)
406
+
407
+ return self._array_receiver_once_expr(array_expr, args, lower_receiver)
362
408
 
363
409
  def _map_method_expr(
364
410
  self, map_expr: str, method: str, args: list[str], spec: TypeSpec | None = None,
@@ -512,12 +558,29 @@ class TypeInferer:
512
558
 
513
559
  def _is_udt_lvalue(self, expr) -> str | None:
514
560
  """If ``expr`` is a *user-defined* UDT lvalue (a bare ``Identifier`` that
515
- names a class-scope ``var``/global UDT member, e.g. ``wyckoffSwingLow``),
516
- return its UDT type name; else ``None``.
561
+ names a class-scope ``var``/global UDT member, e.g. ``wyckoffSwingLow``,
562
+ or an element selected from ``array<UDT>``), return its UDT type name;
563
+ else ``None``.
517
564
 
518
565
  Pine UDTs are reference types, so a local initialised from such an lvalue
519
566
  and then mutated through must write back to the global. Drawing UDTs are
520
567
  handled by the separate ``_uses_drawing`` path and are excluded here."""
568
+ if isinstance(expr, FuncCall):
569
+ callee = expr.callee
570
+ func_name, namespace = self._resolve_callee(callee)
571
+ receiver = None
572
+ if namespace == "array" and func_name in ("get", "first", "last") and expr.args:
573
+ receiver = expr.args[0]
574
+ elif (isinstance(callee, MemberAccess)
575
+ and func_name in ("get", "first", "last")):
576
+ receiver = callee.object
577
+ if receiver is not None:
578
+ spec = self._type_spec_from_expr(receiver)
579
+ elem = spec.element if spec is not None and spec.kind == "array" else None
580
+ if (elem is not None and elem.kind == "udt" and elem.name in self._udt_defs
581
+ and elem.name not in DRAWING_TYPE_TO_CPP):
582
+ return elem.name
583
+ return None
521
584
  if not isinstance(expr, Identifier):
522
585
  return None
523
586
  udt_t = self._udt_var_types.get(expr.name)
@@ -791,6 +854,8 @@ class TypeInferer:
791
854
  return "double"
792
855
  if node.name in self._current_func_param_types:
793
856
  return self._current_func_param_types[node.name]
857
+ if node.name in getattr(self, "_current_func_local_types", {}):
858
+ return self._current_func_local_types[node.name]
794
859
  sym = self.ctx.symbols.resolve(node.name)
795
860
  if sym is not None and getattr(sym, "type_spec", None) is not None:
796
861
  return self._type_spec_to_cpp(sym.type_spec)
@@ -829,9 +894,11 @@ class TypeInferer:
829
894
  if namespace == "str":
830
895
  if func_name == "split":
831
896
  return "std::vector<std::string>"
897
+ if func_name in ("contains", "startswith", "endswith"):
898
+ return "bool"
832
899
  if func_name == "tonumber":
833
900
  return "double"
834
- if func_name == "length":
901
+ if func_name in ("length", "pos"):
835
902
  return "int"
836
903
  return "std::string"
837
904
  if namespace == "ta" and func_name == "pivot_point_levels":
@@ -881,6 +948,12 @@ class TypeInferer:
881
948
  # pine_str_tostring); bare reads must declare std::string.
882
949
  if ename == "format":
883
950
  return "std::string"
951
+ if ename == "timeframe":
952
+ if node.member in ("period", "main_period"):
953
+ return "std::string"
954
+ if node.member == "multiplier":
955
+ return "int"
956
+ return "bool"
884
957
  # syminfo.* type inference: look up in SYMINFO_MEMBER_MAP
885
958
  # and derive C++ type from the expression (na<T>() or function call).
886
959
  if ename == "syminfo":
@@ -888,6 +961,9 @@ class TypeInferer:
888
961
  sym_key = f"syminfo.{node.member}"
889
962
  if sym_key in _pf_sigs.SYMINFO_VARIABLES:
890
963
  return PINE_TYPE_TO_CPP.get(_pf_sigs.SYMINFO_VARIABLES[sym_key], "double")
964
+ spec = self._type_spec_from_expr(node)
965
+ if spec is not None:
966
+ return self._type_spec_to_cpp(spec)
891
967
  if isinstance(node, Ternary):
892
968
  tt = self._infer_type(node.true_val)
893
969
  ft = self._infer_type(node.false_val)
@@ -895,6 +971,12 @@ class TypeInferer:
895
971
  return tt if tt.startswith("std::vector") else ft
896
972
  if tt == "std::string" or ft == "std::string":
897
973
  return "std::string"
974
+ if tt == "double" or ft == "double":
975
+ return "double"
976
+ if tt == "int64_t" or ft == "int64_t":
977
+ return "int64_t"
978
+ if tt == "bool" and ft == "bool":
979
+ return "bool"
898
980
  return tt
899
981
  # Block-as-expression cases: read the type of the last statement of
900
982
  # the first branch / case; matches Pine semantics for ``x = if...``.