@pineforge/codegen-pyodide 0.8.0 → 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
@@ -255,7 +277,12 @@ TA_TUPLE_FIELDS = {
255
277
  # must be promoted to ``int64_t`` so the ``na`` sentinel (``INT64_MIN``)
256
278
  # survives — narrowing to 32-bit ``int`` collapses it to ``0`` and breaks
257
279
  # ``is_na<int>`` detection.
258
- INT64_BUILTINS = {"time", "time_close", "timestamp", "time_tradingday"}
280
+ INT64_BUILTINS = {"time", "time_close", "timenow", "timestamp", "time_tradingday"}
281
+
282
+ # Subset of INT64_BUILTINS spelled as a bare identifier (no call args) in Pine.
283
+ # ``entryTime := time`` parses the RHS as an ``Identifier`` named ``time`` (not a
284
+ # ``FuncCall``), so int64 promotion must match these by identifier name too.
285
+ INT64_BUILTIN_IDENTIFIERS = {"time", "time_close", "timenow"}
259
286
 
260
287
  PINE_TYPE_TO_CPP = {
261
288
  "int": "int", "float": "double", "bool": "bool", "string": "std::string",
@@ -263,15 +290,66 @@ PINE_TYPE_TO_CPP = {
263
290
  PineType.STRING: "std::string", PineType.NA: "double",
264
291
  PineType.UNKNOWN: "double", PineType.VOID: "double",
265
292
  PineType.COLOR: "int",
293
+ # Drawing-objects-as-data: the value-view handle structs (see
294
+ # drawing.hpp). Explicit-hint decls (``var line x``), UDT-method drawing
295
+ # params, and ``_type_for_decl`` resolve through here.
296
+ "line": "Line", "box": "Box", "label": "Label",
297
+ "linefill": "Linefill", "chart.point": "ChartPoint",
266
298
  }
267
299
 
300
+ # ---------------------------------------------------------------------------
301
+ # Drawing-objects-as-data (spec §4.1)
302
+ # ---------------------------------------------------------------------------
303
+ # Pine drawing type name -> C++ handle struct (pineforge/drawing.hpp).
304
+ # ``table`` / ``polyline`` are DELIBERATELY absent — they stay 100% no-op
305
+ # (kept in SKIP_NAMESPACES / SKIP_VAR_TYPES / _DRAWING_TYPES_INIT).
306
+ DRAWING_TYPE_TO_CPP = {
307
+ "line": "Line", "box": "Box", "label": "Label",
308
+ "linefill": "Linefill", "chart.point": "ChartPoint",
309
+ }
310
+ # Function-call namespaces routed to the per-type arena dispatch.
311
+ DRAWING_NS = {"line", "box", "label", "linefill"}
312
+ # Constant member reads (``line.style_solid`` -> "0"). chart.point/linefill
313
+ # carry no style constants.
314
+ DRAWING_STYLE_NS = {"line", "box", "label"}
315
+ # Per-type arena member name on the generated strategy.
316
+ DRAWING_ARENA = {
317
+ "line": "_pf_lines_", "box": "_pf_boxes_",
318
+ "label": "_pf_labels_", "linefill": "_pf_linefills_",
319
+ }
320
+
321
+ # Pine typed array constructors for drawing handles — ``array.new_line(...)``
322
+ # is the typed alias of ``array.new<line>(...)`` (and likewise for box/label/
323
+ # linefill). Maps the constructor name to the drawing element type name, so the
324
+ # element TypeSpec resolves to the C++ handle struct (Line/Box/Label/Linefill).
325
+ ARRAY_DRAWING_NEW_CTORS = {
326
+ "new_line": "line", "new_box": "box",
327
+ "new_label": "label", "new_linefill": "linefill",
328
+ }
329
+ # Pine array methods whose C++ lowering is void (or a non-scalar iterator) and
330
+ # whose Pine semantics is mutate-in-place: a function whose body ends in one of
331
+ # these returns void, so the codegen must emit it as a statement + default
332
+ # return rather than ``return arr.clear();`` / ``return arr.insert(...);``.
333
+ ARRAY_VOID_METHODS = frozenset({
334
+ "push", "unshift", "insert", "clear", "set", "fill",
335
+ "sort", "reverse", "concat",
336
+ })
337
+ # All recognised ``array.new_*`` scalar+drawing constructor names (``new`` itself
338
+ # is template-form and handled separately).
339
+ ARRAY_NEW_CTORS = frozenset(
340
+ {"new_float", "new_int", "new_bool", "new_string"} | set(ARRAY_DRAWING_NEW_CTORS)
341
+ )
342
+
268
343
  SKIP_FUNC_NAMES = {
269
344
  "plot", "plotshape", "plotchar", "plotcandle", "plotbar", "plotarrow",
270
345
  "fill", "hline", "barcolor", "bgcolor", "alert", "alertcondition",
271
346
  }
347
+ # line / box / label / linefill REMOVED — they now route to real drawing
348
+ # codegen (gated on _uses_drawing). table / polyline / chart / display / size
349
+ # / position stay no-op.
272
350
  SKIP_NAMESPACES = {
273
- "table", "label", "line", "box", "polyline", "chart",
274
- "linefill", "display", "size", "position",
351
+ "table", "polyline", "chart",
352
+ "display", "size", "position",
275
353
  }
276
354
  SKIP_VAR_TYPES = {"table"}
277
355
 
@@ -578,6 +656,33 @@ MATRIX_METHODS = {
578
656
  # Math / String dispatch
579
657
  # ---------------------------------------------------------------------------
580
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
+
581
686
  MATH_FUNC_MAP = {
582
687
  "abs": "std::abs", "max": "std::max", "min": "std::min",
583
688
  "ceil": "std::ceil", "floor": "std::floor", "round": "std::round",