@pineforge/codegen-pyodide 0.8.0 → 0.8.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.
@@ -90,6 +90,7 @@ from ..analyzer import FuncInfo
90
90
  from ..symbols import TypeSpec
91
91
  from .tables import (
92
92
  BAR_SERIES_PUSH,
93
+ DRAWING_TYPE_TO_CPP,
93
94
  PINE_TYPE_TO_CPP,
94
95
  RUNTIME_REGISTER_SECURITY_EVAL_FN,
95
96
  RUNTIME_REGISTER_SECURITY_LOWER_TF_EVAL_FN,
@@ -136,6 +137,11 @@ class TopLevelEmitter:
136
137
  float_spec = TypeSpec.primitive("float")
137
138
  if any(spec.element != float_spec for spec in self._matrix_specs.values()):
138
139
  lines.append('#include <pineforge/generic_matrix.hpp>')
140
+ # Drawing-objects-as-data runtime (line/box/label/linefill arenas +
141
+ # ChartPoint). Gated on _uses_drawing so non-drawing strategies stay
142
+ # byte-identical — mirrors the matrix.hpp gating above.
143
+ if getattr(self, "_uses_drawing", False):
144
+ lines.append('#include <pineforge/drawing.hpp>')
139
145
  lines.append("")
140
146
  # Compatibility shim for the namespace-wrap refactor: unqualified
141
147
  # references to BacktestEngine / Bar / na<T>() / ta::* / etc. resolve
@@ -208,9 +214,29 @@ class TopLevelEmitter:
208
214
  # TA members with ctor args
209
215
  for site in self.ctx.ta_call_sites:
210
216
  if site.ctor_args:
217
+ # If a ctor arg is neither a compile-time literal nor expandable
218
+ # to an input-backed runtime expression, the old code silently
219
+ # emitted period 1 with no overwriting reset — a wrong indicator
220
+ # masquerading as a working one. Refuse loudly instead. Args that
221
+ # DO expand to a runtime expr (input-backed / arithmetic-over-input,
222
+ # incl. function-derived lengths) are safe: the `!_ta_initialized_`
223
+ # reset overwrites the placeholder before the first compute.
224
+ for a in site.ctor_args:
225
+ r = self._resolve_known(a)
226
+ if (not self._is_compile_time_value(r)
227
+ and self._runtime_ctor_arg_for_reset(a) is None):
228
+ self._codegen_error(
229
+ getattr(site, "node", None),
230
+ f"Unsupported TA constructor length '{a}' for "
231
+ f"{site.class_name}: it is neither a compile-time "
232
+ f"constant nor derived from an input, so PineForge "
233
+ f"cannot size the indicator buffer.",
234
+ hint=("Use a literal, an input.*() value, or "
235
+ "arithmetic over those for TA lengths."),
236
+ )
211
237
  resolved = [self._resolve_known(a) for a in site.ctor_args]
212
- # If any ctor arg isn't a compile-time value, use default 1
213
- # (TA in user functions with runtime params)
238
+ # Compile-time placeholder for the init list; the runtime reset
239
+ # (when the arg is input-derived) overwrites it on the first bar.
214
240
  safe_resolved = []
215
241
  for r in resolved:
216
242
  if self._is_compile_time_value(r):
@@ -240,11 +266,24 @@ class TopLevelEmitter:
240
266
  safe = self._safe_name(name)
241
267
  if name in self._array_vars or name in self._map_vars:
242
268
  continue
269
+ # Function-scoped ``var`` members are initialized once-per-variant
270
+ # on first call (see _emit_func_var_init_block); exclude them from
271
+ # the constructor so they are not double-initialized and so their
272
+ # (possibly bar-dependent) initializer is lowered in the function's
273
+ # own scope (with its active var remap for clones).
274
+ if name in getattr(self, "_func_local_var_names", ()):
275
+ continue
243
276
  # UDT-typed var members (``var SDZone z = na``) default-construct to
244
277
  # na via the struct's in-class ``__pf_na = true``; a ctor init like
245
278
  # ``z(na<double>())`` would not type-match the struct member.
246
279
  if name in self._udt_var_types and self._udt_var_types[name] in self._udt_defs:
247
280
  continue
281
+ # Drawing handle var member (L-N3): ``var line x`` / ``var box b``
282
+ # default-construct to {-1} (na). A ``b(na<double>())`` ctor init
283
+ # would not type-match the handle struct — skip it (the in-class
284
+ # member default is the once-only persistent na init).
285
+ if name in self._udt_var_types and self._udt_var_types[name] in DRAWING_TYPE_TO_CPP:
286
+ continue
248
287
  if name not in self.ctx.series_vars:
249
288
  cpp_val = self._resolve_known(init_expr)
250
289
  cpp_val = self._typed_na_init(cpp_val, name, ptype)
@@ -362,20 +401,22 @@ class TopLevelEmitter:
362
401
  lines.append(" security_eval_states_.clear();")
363
402
  for info in self._security_eval_info:
364
403
  tf = info.get("tf")
365
- tf_expr = None
404
+ tf_expr = info.get("tf_expr")
366
405
  if tf:
367
406
  tf_expr = f'"{tf}"'
368
- else:
369
- tf_node = info.get("tf_node")
370
- if tf_node is not None:
371
- if isinstance(tf_node, Identifier) and tf_node.name in self._timeframe_period_vars:
372
- tf_expr = "script_tf_"
373
- else:
374
- raw_tf_expr = self._visit_expr(tf_node)
375
- tf_expr = self._runtime_ctor_arg_for_reset(raw_tf_expr) or raw_tf_expr
407
+ elif not tf_expr:
408
+ # No static tf and no resolvable runtime expression — fall
409
+ # back to the chart timeframe so registration still compiles
410
+ # (e.g. a request.security inside a dead-code UDF, or one
411
+ # whose tf is a function param called with mixed timeframes).
412
+ tf_expr = "input_tf_"
376
413
  if tf_expr:
377
414
  la = "true" if info["lookahead_on"] else "false"
378
415
  go = "true" if info.get("gaps_on") else "false"
416
+ # Heikin-Ashi same-symbol read: emit the 6th arg only when set
417
+ # so every non-HA strategy's generated code stays byte-identical
418
+ # (the engine param defaults to false).
419
+ ha_arg = ", true" if info.get("heikinashi") else ""
379
420
  sec_id = info["sec_id"]
380
421
  # The runtime registration function is named in tables.py
381
422
  # to keep a single source of truth and to avoid embedding
@@ -391,7 +432,7 @@ class TopLevelEmitter:
391
432
  lines.append(
392
433
  f" {RUNTIME_REGISTER_SECURITY_EVAL_FN}"
393
434
  f"({sec_id}, {tf_expr}, "
394
- f"input_tf_, {la}, {go});")
435
+ f"input_tf_, {la}, {go}{ha_arg});")
395
436
  lines.append(" }")
396
437
 
397
438
  # Map strategy series member name to push expression
@@ -445,6 +486,10 @@ class TopLevelEmitter:
445
486
  if self.ctx.var_members:
446
487
  lines.append(" if (!_var_initialized) {")
447
488
  for name, ptype, init_expr in self.ctx.var_members:
489
+ # Function-scoped ``var`` members are init'd once-per-variant
490
+ # on first call (see _emit_func_var_init_block), not here.
491
+ if name in getattr(self, "_func_local_var_names", ()):
492
+ continue
448
493
  safe = self._safe_name(name)
449
494
  if name in self._array_vars:
450
495
  for stmt in self.ctx.ast.body:
@@ -545,6 +590,7 @@ class TopLevelEmitter:
545
590
  default_cpp = self._visit_expr(default) if default is not None else "0"
546
591
  title = self._get_input_title(stmt.value, var_name=stmt.name)
547
592
  getter = self._input_type_to_getter(func_name_i, namespace_i)
593
+ default_cpp = self._coerce_string_input_default(getter, default_cpp)
548
594
  cpp_val = f'{getter}("{title}", {default_cpp})'
549
595
  static_vars.append(f"{safe} = {cpp_val};")
550
596
 
@@ -709,11 +755,17 @@ class TopLevelEmitter:
709
755
  base = fi.node.name if fi.node else ""
710
756
  return self._func_safe_name(f"_udt_{udt}_{base}")
711
757
 
712
- def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | None = None) -> None:
758
+ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | None = None,
759
+ instance: dict | None = None) -> None:
713
760
  """Emit a user-defined function as a class method.
714
761
 
715
762
  If call_site_idx is not None, emit a per-call-site variant with
716
763
  TA member names remapped to call-site-specific copies.
764
+
765
+ If ``instance`` is provided (a fresh context-sensitive instance minted by
766
+ ``_build_func_instances``), emit a uniquely-named clone whose TA/var
767
+ members come from the instance's composed remaps instead of the flat
768
+ ``_func_cs_*_remap`` tables.
717
769
  """
718
770
  node = fi.node
719
771
  if node is None:
@@ -729,7 +781,12 @@ class TopLevelEmitter:
729
781
  func_sv = self.ctx.func_series_vars.get(fi.name, set())
730
782
  for i, p in enumerate(node.params):
731
783
  if is_udt and i == 0 and fi.udt_type_name:
732
- cpp_t = f"{fi.udt_type_name}&"
784
+ # A method receiver whose type is a drawing primitive
785
+ # (egoigor's ``method slope(line ln)``) must emit ``Line&`` not
786
+ # the unknown ``line&``. Register _udt_param_udt so the body's
787
+ # getters dispatch through the §4.3 drawing path (L.6d / U.5).
788
+ recv_cpp = DRAWING_TYPE_TO_CPP.get(fi.udt_type_name, fi.udt_type_name)
789
+ cpp_t = f"{recv_cpp}&"
733
790
  safe_p = self._safe_name(p)
734
791
  self._udt_param_udt[safe_p] = fi.udt_type_name
735
792
  self._udt_param_udt[p] = fi.udt_type_name
@@ -739,6 +796,24 @@ class TopLevelEmitter:
739
796
  # This param uses history access (e.g. src[1]) — pass as Series
740
797
  cpp_t = "const Series<double>&"
741
798
  self._current_func_series_params.add(p)
799
+ elif i < len(getattr(fi, "param_type_specs", [])) and fi.param_type_specs[i] is not None:
800
+ # Precise per-param TypeSpec (declared hint or call-site inference):
801
+ # ``pivot hi`` -> ``pivot&``, ``line ln`` -> ``Line&``, an untyped
802
+ # ``s`` used as a string -> ``std::string``. UDT / collection
803
+ # params pass by reference (Pine UDTs/arrays are reference types,
804
+ # so mutations propagate and member access compiles).
805
+ spec = fi.param_type_specs[i]
806
+ cpp_t = self._type_spec_to_cpp(spec)
807
+ if spec.kind == "udt":
808
+ self._udt_param_udt[p] = spec.name
809
+ self._udt_param_udt[self._safe_name(p)] = spec.name
810
+ cpp_t = f"{cpp_t}&"
811
+ elif spec.kind in ("array", "map"):
812
+ elem = spec.element if spec.kind == "array" else spec.value
813
+ if elem is not None and elem.kind == "udt":
814
+ self._udt_param_udt[p] = elem.name
815
+ self._udt_param_udt[self._safe_name(p)] = elem.name
816
+ cpp_t = f"{cpp_t}&"
742
817
  elif i < len(fi.param_types):
743
818
  pt = fi.param_types[i]
744
819
  cpp_t = PINE_TYPE_TO_CPP.get(pt, "double")
@@ -757,13 +832,31 @@ class TopLevelEmitter:
757
832
  tuple_types_list = self._infer_tuple_types(node, fi.tuple_element_count)
758
833
  ret_type = f"std::tuple<{', '.join(tuple_types_list)}>"
759
834
  elif getattr(fi, "udt_return_type", None):
760
- ret_type = fi.udt_return_type
835
+ # A function returning a drawing handle must emit the C++ handle
836
+ # struct (Line/Box/Label/Linefill), not the unknown lowercase name.
837
+ ret_type = DRAWING_TYPE_TO_CPP.get(fi.udt_return_type, fi.udt_return_type)
838
+ elif getattr(fi, "return_type_spec", None) is not None:
839
+ # Array-returning function (``f() => array.from(...)``) — emit the
840
+ # vector type from the inferred element TypeSpec.
841
+ ret_type = self._type_spec_to_cpp(fi.return_type_spec)
761
842
  else:
762
843
  ret_type = PINE_TYPE_TO_CPP.get(fi.return_type, "double")
763
844
 
764
845
  # For per-call-site variants, suffix the function name and activate TA + var remapping
765
846
  func_name = self._emit_udt_method_cpp_name(fi) if is_udt else self._func_safe_name(fi.name)
766
- if call_site_idx is not None:
847
+ var_init_flag: str | None = None
848
+ if instance is not None:
849
+ # Fresh context-sensitive instance: name + composed remaps come from
850
+ # the instance record. No textual cs index (dispatch is via the
851
+ # instance map), but it IS a state-isolated variant.
852
+ func_name = instance["name"]
853
+ self._active_ta_remap = instance["ta_remap"]
854
+ self._active_var_remap = instance["var_remap"]
855
+ self._in_ta_func_variant = True
856
+ self._active_call_site_idx = None
857
+ self._current_instance_name = instance["name"]
858
+ var_init_flag = f"_fvinit_{instance['name']}"
859
+ elif call_site_idx is not None:
767
860
  func_name = f"{func_name}_cs{call_site_idx}"
768
861
  remap = self._func_cs_ta_remap.get((fi.name, call_site_idx), {})
769
862
  self._active_ta_remap = remap
@@ -771,13 +864,27 @@ class TopLevelEmitter:
771
864
  self._active_var_remap = var_remap
772
865
  self._in_ta_func_variant = True
773
866
  self._active_call_site_idx = call_site_idx
867
+ self._current_instance_name = f"{self._func_safe_name(fi.name)}_cs{call_site_idx}"
774
868
  else:
775
869
  self._active_ta_remap = {}
776
870
  self._active_var_remap = {}
777
871
  self._in_ta_func_variant = False
778
872
  self._active_call_site_idx = None
873
+ self._current_instance_name = None
779
874
 
780
875
  prev_func_locals = self._current_func_locals
876
+ prev_func_body = getattr(self, "_current_func_body", None)
877
+ prev_func_name = getattr(self, "_active_func_name", None)
878
+ # The function body is the lexical scope used by the UDT-alias analysis
879
+ # (BUG C): a local initialised from a var/global UDT lvalue and later
880
+ # mutated through must alias, not value-copy.
881
+ self._current_func_body = node.body
882
+ self._active_func_name = fi.name
883
+ # Pointer-aliased UDT locals are function-scoped: a name like ``p_ivot``
884
+ # may be a rebinding pointer alias in one function and a ``pivot&``
885
+ # parameter in another, so reset per function to avoid cross-contamination.
886
+ prev_ptr_alias = self._udt_ptr_alias_locals
887
+ self._udt_ptr_alias_locals = set()
781
888
  self._current_func_locals = {n for n, _, _ in self.ctx.func_var_members.get(fi.name, [])}
782
889
  # Plain (non-persistent) scalar locals are emitted inline and live in
783
890
  # no other set; collect them so the unknown-identifier guard in
@@ -786,24 +893,47 @@ class TopLevelEmitter:
786
893
 
787
894
  lines.append(f" {ret_type} {func_name}({', '.join(param_strs)}) {{")
788
895
 
896
+ # Function-scoped ``var`` one-shot initializer: Pine ``var`` inside a
897
+ # function is a function-local static — its initializer runs exactly
898
+ # once, on the first call to THIS variant, with the first bar's values
899
+ # the function actually sees. Each clone (cs0/cs1/…) is independent.
900
+ self._emit_func_var_init_block(fi, call_site_idx, lines, flag_override=var_init_flag)
901
+
789
902
  emitted_return = False
790
903
  if node.is_single_expr and node.body:
791
904
  expr = node.body[0].expr if isinstance(node.body[0], ExprStmt) else None
792
- if expr:
905
+ if expr and self._call_is_void(expr):
906
+ # void setter as the sole body expr — emit as statement, fall
907
+ # through to the default return.
908
+ self._visit_stmt(node.body[0], lines, indent=2)
909
+ elif expr:
793
910
  lines.append(f" return {self._visit_expr(expr)};")
794
911
  emitted_return = True
795
912
  else:
796
913
  for i, s in enumerate(node.body):
797
914
  if i == len(node.body) - 1 and isinstance(s, ExprStmt):
798
- lines.append(f" return {self._visit_expr(s.expr)};")
799
- emitted_return = True
915
+ # A void drawing setter / delete / visual-noop, or a dropped
916
+ # table/polyline method call (``panel.cell(...)``), used as
917
+ # the last statement cannot be the return value (it lowers to
918
+ # a void / no-op C++ call). Emit it as a plain statement
919
+ # (which ``_is_skip_expr`` drops) and let the default-return
920
+ # path below supply the function's result.
921
+ if self._call_is_void(s.expr) or self._is_skip_expr(s.expr):
922
+ self._visit_stmt(s, lines, indent=2)
923
+ else:
924
+ lines.append(f" return {self._visit_expr(s.expr)};")
925
+ emitted_return = True
800
926
  elif i == len(node.body) - 1 and isinstance(s, (SwitchStmt, IfStmt)):
801
927
  # Switch/if as last statement = return expression in PineScript
802
928
  # Emit as: double _ret = 0; if/switch assigns _ret; return _ret;
803
- default_ret = (
804
- f"{ret_type}{{}}" if ret_type in self._udt_defs
805
- else self._default_for_type(ret_type)
806
- )
929
+ # A drawing-handle / UDT return type must brace-init its
930
+ # default (``Label _func_ret = Label{};``) — falling through
931
+ # to ``_default_for_type`` would emit ``0.0`` and clang would
932
+ # reject ``Label _func_ret = 0.0;``.
933
+ if ret_type in self._udt_defs or ret_type in DRAWING_TYPE_TO_CPP.values():
934
+ default_ret = f"{ret_type}{{}}"
935
+ else:
936
+ default_ret = self._default_for_type(ret_type)
807
937
  lines.append(f" {ret_type} _func_ret = {default_ret};")
808
938
  self._visit_if_switch_expr(s, "_func_ret", lines, indent=2)
809
939
  lines.append(f" return _func_ret;")
@@ -818,10 +948,10 @@ class TopLevelEmitter:
818
948
  default_vals = ", ".join(["0.0"] * fi.tuple_element_count)
819
949
  lines.append(f" return std::make_tuple({default_vals});")
820
950
  else:
821
- default_ret = (
822
- f"{ret_type}{{}}" if ret_type in self._udt_defs
823
- else self._default_for_type(ret_type)
824
- )
951
+ if ret_type in self._udt_defs or ret_type in DRAWING_TYPE_TO_CPP.values():
952
+ default_ret = f"{ret_type}{{}}"
953
+ else:
954
+ default_ret = self._default_for_type(ret_type)
825
955
  lines.append(f" return {default_ret};")
826
956
 
827
957
  lines.append(" }")
@@ -829,10 +959,76 @@ class TopLevelEmitter:
829
959
  self._current_func_series_params = set()
830
960
  self._udt_param_udt = {}
831
961
  self._current_func_locals = prev_func_locals
962
+ self._current_func_body = prev_func_body
963
+ self._active_func_name = prev_func_name
964
+ self._udt_ptr_alias_locals = prev_ptr_alias
832
965
  self._active_ta_remap = {}
833
966
  self._active_var_remap = {}
834
967
  self._in_ta_func_variant = False
835
968
  self._active_call_site_idx = None
969
+ self._current_instance_name = None
970
+
971
+ def _func_var_init_flag_name(self, fname: str, call_site_idx: int | None) -> str:
972
+ suffix = f"_cs{call_site_idx}" if call_site_idx is not None else ""
973
+ return f"_fvinit_{self._func_safe_name(fname)}{suffix}"
974
+
975
+ def _emit_func_var_init_block(self, fi: FuncInfo, call_site_idx: int | None,
976
+ lines: list[str], flag_override: str | None = None) -> None:
977
+ """Emit the one-shot initializer block for a function's ``var`` members.
978
+
979
+ Pine ``var`` declared inside a function is a function-local static:
980
+ the initializer runs exactly once on the FIRST call to this variant
981
+ (using the first bar's values the function actually sees) and the
982
+ result persists for the strategy's lifetime. Each per-call-site clone
983
+ is an independent instance with its own flag and its own set of
984
+ (remapped) members.
985
+
986
+ This closes the gap where a function-scoped ``var line x = line.new(...)``
987
+ (or any non-compile-time initializer — drawing handles, UDT ctors,
988
+ arrays, runtime expressions) was declared as a default-constructed
989
+ class member but its initializer was dropped, leaving the member ``na``
990
+ / uninitialised and causing "drawing access on na handle" at runtime.
991
+ """
992
+ members = self.ctx.func_var_members.get(fi.name)
993
+ if not members:
994
+ return
995
+ flag = flag_override or self._func_var_init_flag_name(fi.name, call_site_idx)
996
+ # ``_active_var_remap`` is already set for this variant by the caller,
997
+ # so lowering each init expression here correctly resolves references
998
+ # to sibling var members (which are themselves remapped for clones).
999
+ init_lines: list[str] = []
1000
+ for name, ptype, _init_str in members:
1001
+ init_ast = self.ctx.var_member_init_exprs.get(name)
1002
+ safe = self._safe_name(name)
1003
+ target = self._active_var_remap.get(safe, safe)
1004
+ if name in self.ctx.series_vars:
1005
+ if init_ast is None:
1006
+ continue
1007
+ init_cpp = self._visit_expr(init_ast)
1008
+ init_cpp = self._typed_na_init(init_cpp, name, ptype)
1009
+ init_lines.append(f" {target}.push({init_cpp});")
1010
+ continue
1011
+ if init_ast is None:
1012
+ # No initializer to lower (e.g. bare ``var box b``); leave the
1013
+ # member at its default-constructed value.
1014
+ continue
1015
+ # Skip a plain ``na`` initializer for drawing handles / UDTs whose
1016
+ # default-constructed member is already the na sentinel; assigning
1017
+ # ``na<double>()`` would not type-match the handle / struct.
1018
+ udt_t = self._udt_var_types.get(name)
1019
+ is_drawing = udt_t in DRAWING_TYPE_TO_CPP if udt_t else False
1020
+ is_udt = udt_t in self._udt_defs if udt_t else False
1021
+ from ..ast_nodes import NaLiteral
1022
+ if (is_drawing or is_udt) and isinstance(init_ast, NaLiteral):
1023
+ continue
1024
+ init_cpp = self._visit_expr(init_ast)
1025
+ init_lines.append(f" {target} = {init_cpp};")
1026
+ if not init_lines:
1027
+ return
1028
+ lines.append(f" if (!{flag}) {{")
1029
+ lines.extend(init_lines)
1030
+ lines.append(f" {flag} = true;")
1031
+ lines.append(" }")
836
1032
 
837
1033
  def _emit_precalculate_and_run(self, lines: list[str]) -> None:
838
1034
  has_static_ta = any(getattr(site, "is_static", False) for site in self.ctx.ta_call_sites)
@@ -874,6 +1070,69 @@ class TopLevelEmitter:
874
1070
  push_expr_bars = push_expr.replace("current_bar_.", "bars[i].")
875
1071
  lines.append(f" _s_{field_name}.push({push_expr_bars});")
876
1072
 
1073
+ # Advance the native input.source() backing series (_src_open_ etc.)
1074
+ # from bars[i] too. A static TA site's compute args can reference an
1075
+ # input.source()-derived member (e.g. ``ta.stdev(bbSourceInput, 20)``)
1076
+ # which resolves at runtime to ``get_input_source(...)`` reading one
1077
+ # of these series — normally advanced once per real bar by
1078
+ # ``_push_source_series()`` inside ``dispatch_bar()``, which this
1079
+ # standalone precalc loop never calls. Without this, every static TA
1080
+ # site fed by an input.source() reads an empty series (0.0) for the
1081
+ # entire precalculation, silently corrupting its precalculated
1082
+ # values (e.g. a Bollinger Band's stdev collapsing to 0). Gated on
1083
+ # ``_src_series_active_`` to stay a no-op for scripts with no
1084
+ # input.source() usage; cleared again before the real run begins
1085
+ # (BacktestEngine::run() clears every _src_*_ series unconditionally).
1086
+ lines.append(" if (_src_series_active_) {")
1087
+ lines.append(" const double _pc_o = bars[i].open;")
1088
+ lines.append(" const double _pc_h = bars[i].high;")
1089
+ lines.append(" const double _pc_l = bars[i].low;")
1090
+ lines.append(" const double _pc_c = bars[i].close;")
1091
+ lines.append(" const double _pc_v = bars[i].volume;")
1092
+ lines.append(" _src_open_.push(_pc_o); _src_high_.push(_pc_h); _src_low_.push(_pc_l);")
1093
+ lines.append(" _src_close_.push(_pc_c); _src_volume_.push(_pc_v);")
1094
+ lines.append(" _src_hl2_.push((_pc_h + _pc_l) / 2.0);")
1095
+ lines.append(" _src_hlc3_.push((_pc_h + _pc_l + _pc_c) / 3.0);")
1096
+ lines.append(" _src_ohlc4_.push((_pc_o + _pc_h + _pc_l + _pc_c) / 4.0);")
1097
+ lines.append(" _src_hlcc4_.push((_pc_h + _pc_l + _pc_c + _pc_c) / 4.0);")
1098
+ lines.append(" }")
1099
+
1100
+ # Replay every top-level ``X = input.source(...)`` (or bare
1101
+ # ``X = input(close)``) assignment. A static TA site's compute args
1102
+ # often don't reference ``get_input_source(...)`` inline — they
1103
+ # reference the top-level variable the script bound it to (e.g.
1104
+ # ``bbSourceInput = input.source(close, "BB Source")`` then
1105
+ # ``ta.stdev(bbSourceInput, 20)``). That variable is deliberately
1106
+ # NOT covered by the ``_inputs_initialized_`` once-only static-input
1107
+ # block above (see ``is_static_global_input``'s ``_is_source_input``
1108
+ # exclusion) because it tracks a live per-bar series, not a frozen
1109
+ # config value — under normal per-bar dispatch it is reassigned every
1110
+ # real bar. This precalc loop has no other path that reassigns it, so
1111
+ # without this replay every is_static site downstream of it would
1112
+ # keep reading its ctor-initialized 0.0 for the whole precalculation
1113
+ # even with the ``_src_*_`` fix above.
1114
+ for stmt in self.ctx.ast.body:
1115
+ if not isinstance(stmt, VarDecl):
1116
+ continue
1117
+ if stmt.name not in self._global_member_vars:
1118
+ continue
1119
+ if not (isinstance(stmt.value, FuncCall) and self._is_source_input(stmt.value)):
1120
+ continue
1121
+ safe = self._safe_name(stmt.name)
1122
+ default = self._get_input_default(stmt.value)
1123
+ base = self._source_defval_to_base_series(default)
1124
+ title = self._get_input_title(stmt.value, var_name=stmt.name)
1125
+ cpp_val = f'get_input_source("{title}", {base})[0]'
1126
+ # A source var subscripted elsewhere in the script (e.g. ``src[1]``)
1127
+ # is declared ``Series<double>``, not a scalar double, mirroring
1128
+ # the normal per-bar path's ``{safe}.push({cpp_val})`` (see
1129
+ # ``_visit_var_decl``'s ``node.name in self.ctx.series_vars``
1130
+ # branch) — a plain ``=`` there is a compile error.
1131
+ if stmt.name in self.ctx.series_vars:
1132
+ lines.append(f' {safe}.push({cpp_val});')
1133
+ else:
1134
+ lines.append(f' {safe} = {cpp_val};')
1135
+
877
1136
  # Set _precalc_loop_active = True
878
1137
  self._precalc_loop_active = True
879
1138
  try:
@@ -206,8 +206,22 @@ class InputHelper:
206
206
  default = self._get_input_default(node)
207
207
  default_cpp = self._visit_expr(default) if default is not None else "0"
208
208
  getter = self._input_type_to_getter(func_name, namespace)
209
+ default_cpp = self._coerce_string_input_default(getter, default_cpp)
209
210
  return f'{getter}("{title}", {default_cpp})'
210
211
 
212
+ def _coerce_string_input_default(self, getter: str, default_cpp: str) -> str:
213
+ """String getters take a ``std::string`` default. An unresolved default
214
+ (e.g. ``input.string(size.tiny, ...)`` where ``size.tiny`` lowers to
215
+ ``0``) would pass a null char* and crash ``strlen`` inside the getter,
216
+ so coerce any non-string default to a safe empty string. (Label size is
217
+ a visual no-op in the backtest, so the exact default does not affect
218
+ trading logic.)"""
219
+ if getter == "get_input_string":
220
+ stripped = default_cpp.strip()
221
+ if not stripped.startswith("std::string(") and not stripped.startswith('"'):
222
+ return 'std::string("")'
223
+ return default_cpp
224
+
211
225
  def _enforce_enum_declared_before_input_enum(self, node: FuncCall) -> None:
212
226
  """Mirror the analyzer: ``input.enum(Enum.member)`` requires Enum to be defined above the call.
213
227