@pineforge/codegen-pyodide 0.8.1 → 0.10.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,
@@ -506,18 +552,88 @@ class TypeInferer:
506
552
  return True
507
553
  return name in self._int64_reassign_targets()
508
554
 
555
+ def _na_reassign_cpp_type(self, name: str) -> str | None:
556
+ """Declared scalar C++ type of a ``:=`` reassignment target ``name``, so a
557
+ bare-``na`` RHS (``x := na``) can be spelled ``na<T>()`` matching the
558
+ member/local type instead of the default ``na<double>()``.
559
+
560
+ Assigning a double quiet-NaN into an ``int``/``int64_t``/``bool`` member is
561
+ undefined behaviour (NaN->int is unspecified; on ARM64 it saturates to 0,
562
+ which is not the ``na<T>()`` sentinel) and defeats ``is_na<T>()``. Mirrors
563
+ the member-declaration type logic (``base._emit_class_members`` /
564
+ ``_typed_na_init``): ``PINE_TYPE_TO_CPP`` plus the int->int64_t epoch
565
+ promotion. Returns ``None`` for collections / UDT / drawing handles and
566
+ for ``double`` (already the default lowering), so those paths are
567
+ unchanged.
568
+ """
569
+ # Collections / UDT / drawing handles never take a scalar ``na<T>()``:
570
+ # leave them to the drawing-na / default lowering in _visit_rhs_value.
571
+ if (name in self._array_vars
572
+ or name in self._map_vars
573
+ or name in getattr(self, "_matrix_specs", {})
574
+ or name in self._udt_var_types):
575
+ return None
576
+ cpp_type: str | None = None
577
+ # 1. ``var`` member (class-scope OR function-local: both are recorded in
578
+ # ctx.var_members). This is the authoritative declaration source.
579
+ for vname, ptype, _init in self.ctx.var_members:
580
+ if vname == name:
581
+ cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double")
582
+ break
583
+ # 2. Function-local plain (non-``var``) scalar: its declared type was
584
+ # remembered at the VarDecl (``_type_for_decl``).
585
+ if cpp_type is None:
586
+ cpp_type = getattr(self, "_current_func_local_types", {}).get(name)
587
+ # 3. Function parameter.
588
+ if cpp_type is None:
589
+ cpp_type = getattr(self, "_current_func_param_types", {}).get(name)
590
+ # 4. Global-scope non-``var`` class member.
591
+ if cpp_type is None:
592
+ for gname, gptype in self.ctx.global_var_decls:
593
+ if gname == name:
594
+ cpp_type = PINE_TYPE_TO_CPP.get(gptype, "double")
595
+ break
596
+ if cpp_type is None:
597
+ return None
598
+ # int -> int64_t promotion for epoch-ms builtins, mirroring the member
599
+ # declaration so the na sentinel width matches the storage width.
600
+ if cpp_type == "int" and self._is_int64_builtin_init(name):
601
+ cpp_type = "int64_t"
602
+ # Only the retypeable scalar types are meaningful; ``double`` already
603
+ # lowers to ``na<double>()`` and everything else is left untouched.
604
+ if cpp_type in ("int", "int64_t", "bool", "std::string"):
605
+ return cpp_type
606
+ return None
607
+
509
608
  # ------------------------------------------------------------------
510
609
  # BUG C: user-defined-UDT lvalue aliasing
511
610
  # ------------------------------------------------------------------
512
611
 
513
612
  def _is_udt_lvalue(self, expr) -> str | None:
514
613
  """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``.
614
+ names a class-scope ``var``/global UDT member, e.g. ``wyckoffSwingLow``,
615
+ or an element selected from ``array<UDT>``), return its UDT type name;
616
+ else ``None``.
517
617
 
518
618
  Pine UDTs are reference types, so a local initialised from such an lvalue
519
619
  and then mutated through must write back to the global. Drawing UDTs are
520
620
  handled by the separate ``_uses_drawing`` path and are excluded here."""
621
+ if isinstance(expr, FuncCall):
622
+ callee = expr.callee
623
+ func_name, namespace = self._resolve_callee(callee)
624
+ receiver = None
625
+ if namespace == "array" and func_name in ("get", "first", "last") and expr.args:
626
+ receiver = expr.args[0]
627
+ elif (isinstance(callee, MemberAccess)
628
+ and func_name in ("get", "first", "last")):
629
+ receiver = callee.object
630
+ if receiver is not None:
631
+ spec = self._type_spec_from_expr(receiver)
632
+ elem = spec.element if spec is not None and spec.kind == "array" else None
633
+ if (elem is not None and elem.kind == "udt" and elem.name in self._udt_defs
634
+ and elem.name not in DRAWING_TYPE_TO_CPP):
635
+ return elem.name
636
+ return None
521
637
  if not isinstance(expr, Identifier):
522
638
  return None
523
639
  udt_t = self._udt_var_types.get(expr.name)
@@ -791,6 +907,8 @@ class TypeInferer:
791
907
  return "double"
792
908
  if node.name in self._current_func_param_types:
793
909
  return self._current_func_param_types[node.name]
910
+ if node.name in getattr(self, "_current_func_local_types", {}):
911
+ return self._current_func_local_types[node.name]
794
912
  sym = self.ctx.symbols.resolve(node.name)
795
913
  if sym is not None and getattr(sym, "type_spec", None) is not None:
796
914
  return self._type_spec_to_cpp(sym.type_spec)
@@ -829,9 +947,11 @@ class TypeInferer:
829
947
  if namespace == "str":
830
948
  if func_name == "split":
831
949
  return "std::vector<std::string>"
950
+ if func_name in ("contains", "startswith", "endswith"):
951
+ return "bool"
832
952
  if func_name == "tonumber":
833
953
  return "double"
834
- if func_name == "length":
954
+ if func_name in ("length", "pos"):
835
955
  return "int"
836
956
  return "std::string"
837
957
  if namespace == "ta" and func_name == "pivot_point_levels":
@@ -881,6 +1001,12 @@ class TypeInferer:
881
1001
  # pine_str_tostring); bare reads must declare std::string.
882
1002
  if ename == "format":
883
1003
  return "std::string"
1004
+ if ename == "timeframe":
1005
+ if node.member in ("period", "main_period"):
1006
+ return "std::string"
1007
+ if node.member == "multiplier":
1008
+ return "int"
1009
+ return "bool"
884
1010
  # syminfo.* type inference: look up in SYMINFO_MEMBER_MAP
885
1011
  # and derive C++ type from the expression (na<T>() or function call).
886
1012
  if ename == "syminfo":
@@ -888,6 +1014,9 @@ class TypeInferer:
888
1014
  sym_key = f"syminfo.{node.member}"
889
1015
  if sym_key in _pf_sigs.SYMINFO_VARIABLES:
890
1016
  return PINE_TYPE_TO_CPP.get(_pf_sigs.SYMINFO_VARIABLES[sym_key], "double")
1017
+ spec = self._type_spec_from_expr(node)
1018
+ if spec is not None:
1019
+ return self._type_spec_to_cpp(spec)
891
1020
  if isinstance(node, Ternary):
892
1021
  tt = self._infer_type(node.true_val)
893
1022
  ft = self._infer_type(node.false_val)
@@ -895,6 +1024,12 @@ class TypeInferer:
895
1024
  return tt if tt.startswith("std::vector") else ft
896
1025
  if tt == "std::string" or ft == "std::string":
897
1026
  return "std::string"
1027
+ if tt == "double" or ft == "double":
1028
+ return "double"
1029
+ if tt == "int64_t" or ft == "int64_t":
1030
+ return "int64_t"
1031
+ if tt == "bool" and ft == "bool":
1032
+ return "bool"
898
1033
  return tt
899
1034
  # Block-as-expression cases: read the type of the last statement of
900
1035
  # the first branch / case; matches Pine semantics for ``x = if...``.