@pineforge/codegen-pyodide 0.7.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.
Files changed (37) hide show
  1. package/README.md +25 -0
  2. package/index.mjs +20 -0
  3. package/package.json +32 -0
  4. package/pineforge_codegen/__init__.py +53 -0
  5. package/pineforge_codegen/analyzer/__init__.py +60 -0
  6. package/pineforge_codegen/analyzer/base.py +1566 -0
  7. package/pineforge_codegen/analyzer/call_handlers.py +895 -0
  8. package/pineforge_codegen/analyzer/contracts.py +163 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +118 -0
  10. package/pineforge_codegen/analyzer/tables.py +204 -0
  11. package/pineforge_codegen/analyzer/types.py +261 -0
  12. package/pineforge_codegen/ast_nodes.py +293 -0
  13. package/pineforge_codegen/codegen/__init__.py +78 -0
  14. package/pineforge_codegen/codegen/base.py +1381 -0
  15. package/pineforge_codegen/codegen/emit_top.py +875 -0
  16. package/pineforge_codegen/codegen/helpers.py +163 -0
  17. package/pineforge_codegen/codegen/helpers_syminfo.py +132 -0
  18. package/pineforge_codegen/codegen/input.py +189 -0
  19. package/pineforge_codegen/codegen/security.py +1564 -0
  20. package/pineforge_codegen/codegen/ta.py +298 -0
  21. package/pineforge_codegen/codegen/tables.py +683 -0
  22. package/pineforge_codegen/codegen/types.py +592 -0
  23. package/pineforge_codegen/codegen/visit_call.py +1387 -0
  24. package/pineforge_codegen/codegen/visit_expr.py +729 -0
  25. package/pineforge_codegen/codegen/visit_stmt.py +766 -0
  26. package/pineforge_codegen/errors.py +98 -0
  27. package/pineforge_codegen/lexer.py +531 -0
  28. package/pineforge_codegen/parser.py +1198 -0
  29. package/pineforge_codegen/pragmas.py +117 -0
  30. package/pineforge_codegen/signatures.py +808 -0
  31. package/pineforge_codegen/support_checker.py +1223 -0
  32. package/pineforge_codegen/symbols.py +118 -0
  33. package/pineforge_codegen/tokens.py +406 -0
  34. package/pineforge_codegen/tv_input_choices.py +86 -0
  35. package/pineforge_codegen-0.7.0.tar.gz +0 -0
  36. package/release.json +7 -0
  37. package/tables.json +1653 -0
@@ -0,0 +1,1387 @@
1
+ """Function-call dispatch visitors for the codegen.
2
+
3
+ ``CallVisitor`` mixin holds the function-call dispatcher and the
4
+ per-namespace dispatch helpers (``strategy.*``, ``color.*``, ``str.*``,
5
+ ``math.*``, ``fixnan``).
6
+
7
+ ``_visit_func_call`` is the central entry point for any
8
+ :class:`FuncCall` AST node. It first handles UDT-method calls and
9
+ ``obj.method(args)`` style receivers, then resolves the callee to a
10
+ ``(func_name, namespace)`` pair and dispatches by namespace:
11
+
12
+ * ``strategy.*`` -> ``_visit_strategy_call`` (entry / exit / close /
13
+ cancel / order / closedtrades.* / opentrades.* / convert_to_*).
14
+ * ``ta.*`` -> ``ta.tr`` is inlined; other sites resolve to the
15
+ ``member.compute(...)`` / ``member.recompute(...)`` form via the
16
+ ``TaSiteHelper``; ``ta.pivot_point_levels`` is a free function.
17
+ * ``input`` / ``input.*`` -> runtime ``get_input_*()`` getters via
18
+ ``InputHelper``.
19
+ * ``str.*`` -> ``_visit_str_call`` (tostring / substring / format
20
+ / format_time / replace + the ``STR_FUNC_MAP`` shortcuts).
21
+ * ``math.*`` -> ``_visit_math_call`` (round-to-mintick, todegrees /
22
+ toradians, random, n-ary avg / max / min, ``MATH_FUNC_MAP``).
23
+ * ``color.*`` -> ``_visit_color_call`` (new / r / g / b / t / rgb /
24
+ from_gradient).
25
+ * ``array.*`` / ``map.*`` / ``matrix.*`` -> functional and method-syntax
26
+ forms, delegating to ``TypeInferer``'s ``_array_method_expr`` /
27
+ ``_map_method_expr`` and the ``MATRIX_METHODS`` table.
28
+ * ``request.security``, ``ticker.*``, ``runtime.*``, ``log.*``,
29
+ ``timeframe.*``, ``time`` / ``time_close`` / ``timestamp``, type
30
+ casts ``int`` / ``float`` / ``bool`` / ``string``.
31
+ * UDT constructors (``TypeName.new(...)``) and copies.
32
+
33
+ Anything left over is treated as a generic user-defined or unknown
34
+ function call: the visitor merges kwargs by parameter name (using
35
+ ``FuncInfo`` for user functions or the ``signatures`` registry for
36
+ intrinsics), passes ``Series<T>`` references for series-typed params,
37
+ and emits ``namespace::func(args)`` (or the per-call-site variant
38
+ ``func_csN`` for functions cloned by the analyzer's call-site
39
+ splitter).
40
+
41
+ ``_visit_fixnan`` allocates a fresh persistent state member each time
42
+ it is called (``_prev_fixnan_<n>``).
43
+
44
+ ``_resolve_func_args`` is a small helper that merges positional args
45
+ and kwargs into a ``{param_name: arg_node}`` dict using the parameter
46
+ ordering from the ``signatures`` registry; used by
47
+ ``_visit_strategy_call`` to resolve ``strategy.entry`` /
48
+ ``strategy.exit`` / ... by keyword.
49
+
50
+ These visitors were extracted from ``base.py``'s ``CodeGen`` class as
51
+ step 10 of the codegen package refactor; behaviour is preserved
52
+ verbatim. The mixin owns no state of its own — it reads/writes only
53
+ attributes already established on the host class (``CodeGen``).
54
+
55
+ Mixin contract — host class must provide the following attributes
56
+ (all set by ``CodeGen.__init__`` or other mixins):
57
+
58
+ - ``self.ctx`` (``AnalyzerContext``): symbol table source. The
59
+ visitors read ``ctx.symbols.resolve`` (``_visit_str_call`` enum
60
+ branch), ``ctx.series_vars`` (series-arg lowering),
61
+ ``ctx.func_series_vars`` (per-function series-param indices),
62
+ ``ctx.func_call_cs_map`` and ``ctx.func_call_site_counts``
63
+ (per-call-site variant naming).
64
+ - ``self._var_names`` (``set[str]``): names declared at module scope;
65
+ consulted by the ``obj.method`` receiver-detection branch.
66
+ - ``self._global_member_vars`` (``set[str]``): non-``var`` global
67
+ declarations emitted as class members (same branch).
68
+ - ``self._current_func_param_types`` (``dict[str, ...]``): parameters
69
+ of the function currently being emitted (treated as locals for the
70
+ receiver guard).
71
+ - ``self._current_loop_vars`` (``set[str]``): for-in iterator names
72
+ (also receivers of ``.method()`` calls).
73
+ - ``self._current_input_var_name`` (``str | None``): contextual var
74
+ name used as the title-fallback for ``input(...)`` calls.
75
+ - ``self._array_vars`` / ``self._map_vars`` (``set[str]``) and
76
+ ``self._matrix_specs`` (``dict[str, TypeSpec]``): collection-typed
77
+ variables; gate the receiver-method branches in ``_visit_func_call``.
78
+ - ``self._udt_defs`` (``dict[str, list]``), ``self._udt_var_types``
79
+ (``dict[str, str]``), ``self._udt_param_udt`` (``dict[str, str]``),
80
+ ``self._udt_field_type_specs`` (``dict[str, dict[str, TypeSpec]]``):
81
+ UDT type info for ``TypeName.new(...)`` constructors,
82
+ ``TypeName.copy(...)``, and the ``obj.method()`` UDT-method dispatch.
83
+ - ``self._enum_member_strings`` (``dict[str, list[str]]``): enum -->
84
+ display-string table; used by ``_visit_str_call`` to render
85
+ ``str.tostring(enumVar)`` as the field title rather than the int
86
+ index.
87
+ - ``self._func_info_map`` (``dict[str, FuncInfo]``): user-defined
88
+ function lookup; drives kwarg merging, UDT method dispatch, and the
89
+ series-arg classification.
90
+ - ``self._func_names`` (``set[str]``): user-defined function names;
91
+ controls when ``_func_safe_name`` is applied and when the
92
+ per-call-site variant naming kicks in.
93
+ - ``self._security_calls`` (``list[dict]``): normalized
94
+ ``request.security`` records; matched by ``expr_node`` identity to
95
+ bind ``_req_sec_<id>`` result names.
96
+ - ``self._active_call_site_idx`` (``int | None``): set during
97
+ per-call-site function emission; controls ``_csN`` variant naming
98
+ for sub-function calls.
99
+ - ``self._active_var_remap`` (``dict[str, str]``): per-call-site
100
+ rename map for cloned function-local var/series names; consulted by
101
+ the series-arg lowering helper.
102
+ - ``self._fixnan_counter`` (``int``): monotonically incremented by
103
+ ``_visit_fixnan`` to mint fresh ``_prev_fixnan_<n>`` member names.
104
+ - ``self._random_call_counter`` (``int``): monotonically incremented
105
+ by ``_visit_math_call`` for ``math.random`` so each call gets a
106
+ unique site id (used to seed the runtime PRNG).
107
+
108
+ Sibling-mixin methods consumed via ``self``:
109
+
110
+ - ``NamingHelper`` (``codegen/helpers.py``): ``_safe_name``,
111
+ ``_resolve_callee``, ``_func_safe_name``.
112
+ - ``TypeInferer`` (``codegen/types.py``): ``_type_spec_to_cpp``,
113
+ ``_type_spec_from_expr``, ``_type_spec_from_hint_name``,
114
+ ``_default_for_spec``, ``_array_method_expr``,
115
+ ``_map_method_expr``, ``_array_spec_for_name``,
116
+ ``_map_spec_for_name``.
117
+ - ``TaSiteHelper`` (``codegen/ta.py``): ``_get_ta_site``,
118
+ ``_ta_member_name``, ``_ta_compute_args_for_site``.
119
+ - ``InputHelper`` (``codegen/input.py``): ``_is_input_call_by_name``,
120
+ ``_get_input_default``, ``_get_input_title``,
121
+ ``_input_type_to_getter``,
122
+ ``_enforce_enum_declared_before_input_enum``.
123
+ - ``TopLevelEmitter`` (``codegen/emit_top.py``):
124
+ ``_emit_udt_method_cpp_name``.
125
+ - ``ExprVisitor`` (``codegen/visit_expr.py``): ``_visit_expr``.
126
+ - ``CodeGen.base``: ``_codegen_error``.
127
+
128
+ The mixin avoids importing from ``base.py`` to stay free of cycles;
129
+ all tables it needs come from ``codegen/tables.py``, AST classes from
130
+ ``..ast_nodes``, and PineScript signatures from ``.. import signatures``.
131
+ """
132
+
133
+ from __future__ import annotations
134
+
135
+ from ..ast_nodes import (
136
+ FuncCall,
137
+ Identifier,
138
+ MemberAccess,
139
+ TupleLiteral,
140
+ StringLiteral,
141
+ )
142
+ from ..symbols import TypeSpec
143
+ from .. import signatures as sigs
144
+ from .tables import (
145
+ ARRAY_METHODS,
146
+ BAR_FIELDS,
147
+ BAR_SERIES_PUSH,
148
+ MAP_METHODS,
149
+ MATH_FUNC_MAP,
150
+ MATRIX_METHODS,
151
+ MATRIX_METHOD_KWARGS,
152
+ MATRIX_NUMERIC_ONLY,
153
+ MATRIX_SORT_ALLOWED_GENERIC_ELEMS,
154
+ SKIP_FUNC_NAMES,
155
+ SKIP_NAMESPACES,
156
+ SKIP_VAR_TYPES,
157
+ STR_FUNC_MAP,
158
+ TIME_FIELD_EXPRS,
159
+ _merge_kwargs,
160
+ _merge_kwargs_with_defaults,
161
+ tz_time_field_lambda,
162
+ )
163
+
164
+
165
+ def _parse_pine_datestring_ms(text: str) -> int | None:
166
+ """Parse a Pine ``timestamp(dateString)`` literal to Unix milliseconds.
167
+
168
+ Pine v6 accepts ISO-8601 strings ("2025-01-01", "2011-10-10T14:48:00",
169
+ with optional offset) and the "DD MMM YYYY hh:mm:ss ±HHMM" /
170
+ "MMM DD YYYY ..." forms. A dateString without a time zone is GMT+0 per
171
+ the Pine reference. Returns None when the string cannot be parsed.
172
+ """
173
+ from datetime import datetime, timezone
174
+
175
+ txt = text.strip()
176
+ dt = None
177
+ try:
178
+ dt = datetime.fromisoformat(txt)
179
+ except ValueError:
180
+ for fmt in (
181
+ "%d %b %Y %H:%M:%S %z", "%d %b %Y %H:%M %z",
182
+ "%d %b %Y %H:%M:%S", "%d %b %Y %H:%M", "%d %b %Y",
183
+ "%b %d %Y %H:%M:%S %z", "%b %d %Y %H:%M %z",
184
+ "%b %d %Y %H:%M:%S", "%b %d %Y %H:%M", "%b %d %Y",
185
+ ):
186
+ try:
187
+ dt = datetime.strptime(txt, fmt)
188
+ break
189
+ except ValueError:
190
+ continue
191
+ if dt is None:
192
+ return None
193
+ if dt.tzinfo is None:
194
+ dt = dt.replace(tzinfo=timezone.utc)
195
+ return int(dt.timestamp() * 1000)
196
+
197
+
198
+ class CallVisitor:
199
+ """Function-call dispatch visitor methods shared across the codegen.
200
+
201
+ Mixed into ``CodeGen``; not intended to be instantiated standalone.
202
+ See the module docstring for the full host-class state contract."""
203
+
204
+ # ------------------------------------------------------------------
205
+ # Function-call dispatch
206
+ # ------------------------------------------------------------------
207
+
208
+ def _visit_func_call(self, node: FuncCall) -> str:
209
+ callee = node.callee
210
+ if isinstance(callee, MemberAccess):
211
+ recv_spec = self._type_spec_from_expr(callee.object)
212
+ if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name:
213
+ mk = f"{recv_spec.name}.{callee.member}"
214
+ fi_u = self._func_info_map.get(mk)
215
+ if fi_u is not None and getattr(fi_u, "is_udt_method", False):
216
+ fn_cpp = self._emit_udt_method_cpp_name(fi_u)
217
+ recv_e = self._visit_expr(callee.object)
218
+ param_names = list(fi_u.node.params[1:]) if fi_u.node else []
219
+ # Drop the leading ``self`` slot from param_defaults so the
220
+ # parallel array lines up with ``param_names`` (rest of
221
+ # the signature). Probe: udt-method-probe-04-default-param.
222
+ param_defaults = list(getattr(fi_u, "param_defaults", []) or [])[1:]
223
+ rest_nodes = _merge_kwargs_with_defaults(
224
+ node.args, node.kwargs, param_names,
225
+ param_defaults, lambda x: x,
226
+ )
227
+ rest = [self._visit_expr(a) for a in rest_nodes]
228
+ return f"{fn_cpp}({', '.join([recv_e] + rest)})"
229
+ # obj.field.method(args) — must not lower to namespace::method (loses receiver chain).
230
+ if isinstance(callee, MemberAccess):
231
+ obj = callee.object
232
+ if isinstance(obj, MemberAccess):
233
+ root = obj.object
234
+ if not (isinstance(root, Identifier) and root.name in (
235
+ "strategy", "ta", "math", "input", "str", "timeframe", "syminfo",
236
+ "barstate", "color", "request", "runtime", "array", "matrix", "map",
237
+ )):
238
+ recv_spec = self._type_spec_from_expr(obj)
239
+ recv = self._visit_expr(obj)
240
+ meth = callee.member
241
+ raw_args = [self._visit_expr(a) for a in node.args]
242
+ if recv_spec is not None and recv_spec.kind == "array" and meth in ARRAY_METHODS:
243
+ return self._array_method_expr(recv, meth, raw_args, recv_spec)
244
+ if recv_spec is not None and recv_spec.kind == "map" and meth in MAP_METHODS:
245
+ return self._map_method_expr(recv, meth, raw_args, recv_spec)
246
+ args = ", ".join(raw_args)
247
+ if meth == "delete":
248
+ meth = "_delete_"
249
+ return f"{recv}.{meth}({args})"
250
+ # obj.method() where obj is a user var/param — not namespace::method
251
+ if isinstance(obj, Identifier):
252
+ oname = obj.name
253
+ if (
254
+ oname in self._var_names
255
+ or oname in self._current_func_param_types
256
+ or oname in self._current_loop_vars
257
+ or oname in self._global_member_vars
258
+ ):
259
+ meth_raw = callee.member
260
+ # map.put / map.get / … must lower to unordered_map ops, not `.put` on C++
261
+ if oname in self._map_vars and meth_raw in MAP_METHODS:
262
+ m = self._safe_name(oname)
263
+ margs = [self._visit_expr(a) for a in node.args]
264
+ return self._map_method_expr(m, meth_raw, margs, self._map_spec_for_name(oname))
265
+ if oname in self._array_vars and meth_raw in ARRAY_METHODS:
266
+ arr = self._safe_name(oname)
267
+ margs = [self._visit_expr(a) for a in node.args]
268
+ return self._array_method_expr(arr, meth_raw, margs, self._array_spec_for_name(oname))
269
+ if oname in self._matrix_specs and meth_raw in MATRIX_METHODS:
270
+ arr = self._safe_name(oname)
271
+ self._check_matrix_method_allowed(meth_raw, self._matrix_specs[oname], node)
272
+ param_names = MATRIX_METHOD_KWARGS.get(meth_raw)
273
+ if param_names and node.kwargs:
274
+ margs = _merge_kwargs(
275
+ node.args, node.kwargs, param_names, self._visit_expr
276
+ )
277
+ else:
278
+ margs = [self._visit_expr(a) for a in node.args]
279
+ fn = MATRIX_METHODS[meth_raw]
280
+ try:
281
+ return fn(arr, margs)
282
+ except IndexError:
283
+ self._codegen_error(
284
+ node,
285
+ f"matrix.{meth_raw}: wrong number of arguments",
286
+ hint="Check Pine v6 matrix method signature (positional vs keyword).",
287
+ )
288
+ safe_o = self._safe_name(oname)
289
+ udt_t = self._udt_var_types.get(oname) or self._udt_var_types.get(safe_o)
290
+ if udt_t is None:
291
+ udt_t = self._udt_param_udt.get(oname) or self._udt_param_udt.get(safe_o)
292
+ if udt_t is not None:
293
+ mk = f"{udt_t}.{meth_raw}"
294
+ fi_u = self._func_info_map.get(mk)
295
+ if fi_u is not None and getattr(fi_u, "is_udt_method", False):
296
+ fn_cpp = self._emit_udt_method_cpp_name(fi_u)
297
+ recv_e = self._visit_expr(obj)
298
+ param_names = list(fi_u.node.params[1:]) if fi_u.node else []
299
+ # Drop the leading ``self`` slot so param_defaults
300
+ # lines up with ``param_names``. Probe:
301
+ # udt-method-probe-04-default-param.
302
+ param_defaults = list(getattr(fi_u, "param_defaults", []) or [])[1:]
303
+ rest_nodes = _merge_kwargs_with_defaults(
304
+ node.args, node.kwargs, param_names,
305
+ param_defaults, lambda x: x,
306
+ )
307
+ rest = [self._visit_expr(a) for a in rest_nodes]
308
+ return f"{fn_cpp}({', '.join([recv_e] + rest)})"
309
+ args = ", ".join(self._visit_expr(a) for a in node.args)
310
+ recv = self._visit_expr(obj)
311
+ meth = meth_raw
312
+ if meth == "delete":
313
+ meth = "_delete_"
314
+ return f"{recv}.{meth}({args})"
315
+
316
+ func_name, namespace = self._resolve_callee(callee)
317
+
318
+ # na(x) -> is_na(x)
319
+ if func_name == "na" and namespace is None:
320
+ args = ", ".join(self._visit_expr(a) for a in node.args)
321
+ return f"is_na({args})"
322
+
323
+ # nz(x) / nz(x, y)
324
+ if func_name == "nz" and namespace is None:
325
+ x = self._visit_expr(node.args[0])
326
+ y = self._visit_expr(node.args[1]) if len(node.args) > 1 else "0.0"
327
+ return f"(is_na({x}) ? {y} : {x})"
328
+
329
+ # fixnan(x) -> persistent state
330
+ if func_name == "fixnan" and namespace is None:
331
+ return self._visit_fixnan(node)
332
+
333
+ # strategy.* calls
334
+ if namespace == "strategy":
335
+ return self._visit_strategy_call(func_name, node)
336
+
337
+ # ta.tr(handle_na) is dispatched through the standard TA-class path
338
+ # below: the analyzer assigns it a ``ta::TR`` call site (with
339
+ # ``handle_na`` threaded into the constructor); the property form
340
+ # ``ta.tr`` (no parens) stays inline in ``visit_expr`` so its
341
+ # legacy ``handle_na = true`` semantics remain bit-identical.
342
+
343
+ # ta.* calls -> member.compute(...)
344
+ site = self._get_ta_site(node)
345
+ if site is not None:
346
+ compute_args = self._ta_compute_args_for_site(site)
347
+ ta_mem = self._ta_member_name(site)
348
+ if getattr(self, "_precalc_loop_active", False) and getattr(site, "is_static", False):
349
+ return f"_precalc_{ta_mem}[i]"
350
+ if getattr(site, "is_static", False):
351
+ return f"(_use_precalc ? _precalc_{ta_mem}[bar_index_] : (is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args})))"
352
+ return f"(is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args}))"
353
+
354
+ # math.* calls
355
+ if namespace == "math":
356
+ return self._visit_math_call(func_name, node)
357
+
358
+ # input() / input.* calls -> runtime get_input_*()
359
+ if self._is_input_call_by_name(func_name, namespace):
360
+ if namespace == "input" and func_name == "enum":
361
+ self._enforce_enum_declared_before_input_enum(node)
362
+ title = self._get_input_title(node, var_name=self._current_input_var_name)
363
+ return self._render_input_value(node, func_name, namespace, title)
364
+
365
+ # strategy() declaration
366
+ if func_name == "strategy" and namespace is None:
367
+ return "/* strategy declaration */"
368
+
369
+ # str.* calls
370
+ if namespace == "str":
371
+ return self._visit_str_call(func_name, node)
372
+
373
+ # Map method syntax: m.put(key, val) where namespace is the map variable name
374
+ if namespace is not None and namespace in self._map_vars and func_name in MAP_METHODS:
375
+ m = self._safe_name(namespace)
376
+ args = [self._visit_expr(a) for a in node.args]
377
+ return self._map_method_expr(m, func_name, args, self._map_spec_for_name(namespace))
378
+
379
+ # map.method(m, args...) — functional form
380
+ if namespace == "map":
381
+ if func_name == "new":
382
+ spec = self._type_spec_from_expr(node) or TypeSpec.map(TypeSpec.primitive("string"), TypeSpec.primitive("float"))
383
+ return f"{self._type_spec_to_cpp(spec)}()"
384
+ if func_name in MAP_METHODS and node.args:
385
+ m = self._visit_expr(node.args[0])
386
+ rest = [self._visit_expr(a) for a in node.args[1:]]
387
+ spec = self._type_spec_from_expr(node.args[0]) if node.args else None
388
+ return self._map_method_expr(m, func_name, rest, spec)
389
+ return "0"
390
+
391
+ if namespace is not None and namespace in self._matrix_specs and func_name in MATRIX_METHODS:
392
+ arr = self._safe_name(namespace)
393
+ self._check_matrix_method_allowed(func_name, self._matrix_specs[namespace], node)
394
+ param_names = MATRIX_METHOD_KWARGS.get(func_name)
395
+ if param_names and node.kwargs:
396
+ args = _merge_kwargs(node.args, node.kwargs, param_names, self._visit_expr)
397
+ else:
398
+ args = [self._visit_expr(a) for a in node.args]
399
+ fn = MATRIX_METHODS[func_name]
400
+ try:
401
+ return fn(arr, args)
402
+ except IndexError:
403
+ self._codegen_error(
404
+ node,
405
+ f"matrix.{func_name}: wrong number of arguments",
406
+ hint="Check Pine v6 matrix method signature (positional vs keyword).",
407
+ )
408
+
409
+ # Array method syntax: arr.push(val) where namespace is the array variable name
410
+ if namespace is not None and namespace in self._array_vars and func_name in ARRAY_METHODS:
411
+ arr = self._safe_name(namespace)
412
+ args = [self._visit_expr(a) for a in node.args]
413
+ return self._array_method_expr(arr, func_name, args, self._array_spec_for_name(namespace))
414
+
415
+ # Array operations — emit proper C++ vector operations
416
+ if namespace == "array":
417
+ if func_name in ("new", "new_float", "new_int", "new_bool", "new_string"):
418
+ spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
419
+ cpp_type = self._type_spec_to_cpp(spec)
420
+ init_default = self._default_for_spec(spec.element if spec.element is not None else TypeSpec.primitive("float"))
421
+ if node.args:
422
+ size_arg = self._visit_expr(node.args[0])
423
+ init_val = self._visit_expr(node.args[1]) if len(node.args) > 1 else init_default
424
+ return f"{cpp_type}((size_t)({size_arg}), {init_val})"
425
+ return f"{cpp_type}()"
426
+ if func_name == "from":
427
+ spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
428
+ elems = ", ".join(self._visit_expr(a) for a in node.args)
429
+ return f"{self._type_spec_to_cpp(spec)}{{{elems}}}"
430
+ # Method calls: array.method(arr, args...)
431
+ if func_name in ARRAY_METHODS and node.args:
432
+ arr = self._visit_expr(node.args[0])
433
+ rest = [self._visit_expr(a) for a in node.args[1:]]
434
+ spec = self._type_spec_from_expr(node.args[0])
435
+ return self._array_method_expr(arr, func_name, rest, spec)
436
+ return "0"
437
+
438
+ # color.* calls
439
+ if namespace == "color":
440
+ return self._visit_color_call(func_name, node)
441
+
442
+ # Skip visual/unsupported namespace calls
443
+ if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES:
444
+ return "0"
445
+ if func_name in SKIP_FUNC_NAMES and namespace is None:
446
+ return "0"
447
+
448
+ # request.* calls
449
+ if namespace == "request":
450
+ if func_name == "security":
451
+ param_names = ["symbol", "timeframe", "expression", "gaps", "lookahead", "ignore_invalid_symbol", "currency"]
452
+ all_args = list(node.args)
453
+ for i, pname in enumerate(param_names):
454
+ if pname in node.kwargs:
455
+ while len(all_args) <= i:
456
+ all_args.append(None)
457
+ all_args[i] = node.kwargs[pname]
458
+
459
+ # Find matching security call ID
460
+ sec_id = None
461
+ tf_node = None
462
+ expr_node = None
463
+ for item in self._security_calls:
464
+ sid, tfn, exprn = item["sec_id"], item["tf_node"], item["expr_node"]
465
+ if item.get("is_lower_tf_array"):
466
+ continue
467
+ if exprn is all_args[2] if len(all_args) > 2 else False:
468
+ sec_id = sid
469
+ tf_node = tfn
470
+ expr_node = exprn
471
+ break
472
+
473
+ if sec_id is not None and expr_node is not None:
474
+ if isinstance(expr_node, TupleLiteral):
475
+ parts = []
476
+ for i, el in enumerate(expr_node.elements):
477
+ parts.append(f"_req_sec_{sec_id}_{i}")
478
+ return f"std::make_tuple({', '.join(parts)})"
479
+ return f"_req_sec_{sec_id}"
480
+
481
+ # Fallback
482
+ return "na<double>()"
483
+ if func_name == "security_lower_tf":
484
+ # ``request.security_lower_tf`` is matched against the
485
+ # registered SecurityCallInfo by AST identity of the
486
+ # ``expression`` argument (3rd positional or kwarg). The
487
+ # codegen lowers the call to the per-sec_id accumulator
488
+ # vector — its element type and clear/push semantics are
489
+ # set up by the security mixin.
490
+ ltf_param_names = [
491
+ "symbol", "timeframe", "expression",
492
+ "ignore_invalid_symbol", "currency",
493
+ "ignore_invalid_timeframe", "calc_bars_count",
494
+ ]
495
+ ltf_all_args = list(node.args)
496
+ for i, pname in enumerate(ltf_param_names):
497
+ if pname in node.kwargs:
498
+ while len(ltf_all_args) <= i:
499
+ ltf_all_args.append(None)
500
+ ltf_all_args[i] = node.kwargs[pname]
501
+ ltf_expr_node = ltf_all_args[2] if len(ltf_all_args) > 2 else None
502
+ for item in self._security_calls:
503
+ if not item.get("is_lower_tf_array"):
504
+ continue
505
+ if item["expr_node"] is ltf_expr_node:
506
+ return f"_req_sec_lower_tf_{item['sec_id']}"
507
+ return "std::vector<double>{}"
508
+ # All other request.* functions
509
+ return "na<double>()"
510
+
511
+ # ticker.* calls
512
+ if namespace == "ticker":
513
+ # ticker.inherit(symbol, ...) and ticker.standard(symbol) — passthrough:
514
+ # emit the symbol argument unchanged (same-symbol passthrough).
515
+ if func_name in ("inherit", "standard"):
516
+ if node.args:
517
+ return self._visit_expr(node.args[0])
518
+ if "symbol" in node.kwargs:
519
+ return self._visit_expr(node.kwargs["symbol"])
520
+ # All other ticker.* calls are hard-rejected by support_checker;
521
+ # emit empty string as safe fallback if they somehow reach codegen.
522
+ return 'std::string("")'
523
+
524
+ # runtime.error() and other runtime.* calls
525
+ if namespace == "runtime":
526
+ if func_name == "error":
527
+ rt_args = [self._visit_expr(a) for a in node.args]
528
+ msg_arg = rt_args[0] if rt_args else '""'
529
+ return f'pine_runtime_error({msg_arg})'
530
+ return '"" /* unsupported runtime */'
531
+
532
+ # year(time) / month(time) / dayofmonth(time) / dayofweek(time) /
533
+ # hour(time[, tz]) / minute(time[, tz]) / second(time[, tz]) /
534
+ # weekofyear(time[, tz]).
535
+ #
536
+ # Pine v6 exposes these names as BOTH variables (current bar) AND
537
+ # functions (arbitrary timestamp). Both forms now share the same
538
+ # timezone-aware emission: the variable form is wired by
539
+ # ``BAR_BUILTINS`` in codegen/tables.py to
540
+ # ``tz_time_field_lambda(..., current_bar_.timestamp,
541
+ # syminfo_.timezone)`` and the function form below uses the same
542
+ # builder, so the numbers agree across both forms.
543
+ #
544
+ # Timezone handling (per Pine v6 reference docs):
545
+ # - Bare form ``hour(time)`` defaults its tz argument to
546
+ # ``syminfo.timezone`` — the SYMBOL/EXCHANGE timezone, NOT the
547
+ # chart's display timezone. For the corpus' ETH-USDT crypto data
548
+ # this is ``"UTC"`` (the ``SymInfo`` constructor default), which
549
+ # keeps the lambda on the cheap ``gmtime_r`` fast path —
550
+ # value-identical to the engine's ``_bar_hour()`` /
551
+ # ``_decompose_bar_time()`` (engine.hpp) UTC helpers the variable
552
+ # form used to bind to.
553
+ #
554
+ # Pre-fix the harness's ``strategy_set_chart_timezone`` clobbered
555
+ # ``syminfo_.timezone`` with the chart display TZ, which silently
556
+ # shifted ``hour(time)``-bucketed accumulators by the
557
+ # chart-vs-exchange offset (Asia/Taipei vs UTC = +8h). That fix
558
+ # now lives entirely in ``BacktestEngine::set_chart_timezone``
559
+ # (engine.hpp), which writes to a dedicated ``chart_timezone_``
560
+ # slot and leaves ``syminfo_.timezone`` at its constructor
561
+ # default. This codegen still reads ``syminfo_.timezone``,
562
+ # matching TV semantics, with no emit-time changes.
563
+ # - Two-arg form ``hour(time, tz)`` always overrides syminfo with
564
+ # the explicit tz argument. Same setenv+localtime_r block as the
565
+ # 1-arg fallback.
566
+ if (
567
+ namespace is None
568
+ and func_name in TIME_FIELD_EXPRS
569
+ and (node.args or node.kwargs)
570
+ ):
571
+ params = sigs.get_param_names(None, func_name)
572
+ args = _merge_kwargs(node.args, node.kwargs, params, self._visit_expr)
573
+ ts_arg = args[0] if args else "current_bar_.timestamp"
574
+ tz_arg = args[1] if len(args) > 1 else None
575
+ field_expr = TIME_FIELD_EXPRS[func_name]
576
+ if tz_arg is None:
577
+ # 1-arg form — fall back to ``syminfo.timezone`` per TV
578
+ # docs (the EXCHANGE TZ, default "UTC" for the corpus'
579
+ # crypto data; NOT the chart display TZ — that lives in
580
+ # ``chart_timezone_`` on the engine and is intentionally
581
+ # ignored here). UTC / "" / "Etc/UTC" stay on the cheap
582
+ # gmtime_r path; anything else takes the same
583
+ # mutex-guarded setenv+localtime_r block as the 2-arg
584
+ # form.
585
+ tz_arg = "syminfo_.timezone"
586
+ # 2-arg form — honor the tz argument. The shared
587
+ # ``tz_time_field_lambda`` (codegen/tables.py) also backs the
588
+ # bare variable forms (``hour`` etc. via BAR_BUILTINS), so the
589
+ # numbers agree across both forms.
590
+ return tz_time_field_lambda(field_expr, ts_arg, tz_arg)
591
+
592
+ # time(timeframe) or time(timeframe, session[, tz])
593
+ if func_name == "time" and namespace is None and (node.args or node.kwargs):
594
+ args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time"), self._visit_expr)
595
+ tf_e = args[0] if len(args) > 0 else 'script_tf_'
596
+ sess = args[1] if len(args) > 1 else 'std::string("")'
597
+ tz_e = args[2] if len(args) > 2 else 'std::string("")'
598
+ return (
599
+ f"pine_time(current_bar_.timestamp, {tf_e}, {sess}, {tz_e}, script_tf_)"
600
+ )
601
+ # time_close(timeframe) or time_close(tf, session, tz)
602
+ if func_name == "time_close" and namespace is None and (node.args or node.kwargs):
603
+ args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time_close"), self._visit_expr)
604
+ tf_e = args[0] if len(args) > 0 else 'script_tf_'
605
+ sess = args[1] if len(args) > 1 else 'std::string("")'
606
+ tz_e = args[2] if len(args) > 2 else 'std::string("")'
607
+ return (
608
+ f"pine_time_close(current_bar_.timestamp, {tf_e}, {sess}, {tz_e}, script_tf_)"
609
+ )
610
+
611
+ # timestamp(year, month, day, hour, minute) → Unix ms
612
+ if func_name == "timestamp" and namespace is None:
613
+ is_tz_first = False
614
+ if node.args:
615
+ first_arg_spec = self._type_spec_from_expr(node.args[0])
616
+ if first_arg_spec is not None and first_arg_spec.kind == "primitive" and first_arg_spec.name == "string":
617
+ is_tz_first = True
618
+ elif isinstance(node.args[0], StringLiteral):
619
+ is_tz_first = True
620
+
621
+ if is_tz_first:
622
+ # A single string argument is the timestamp(dateString)
623
+ # overload, NOT the timezone-first form. It used to fall
624
+ # through with year=1970 defaults — silently wrong. Pine
625
+ # dateString is a const string, so parse it at transpile
626
+ # time (common as the input.time defval); reject loudly when
627
+ # it is not a literal or does not parse.
628
+ if len(node.args) == 1:
629
+ if isinstance(node.args[0], StringLiteral):
630
+ ms = _parse_pine_datestring_ms(node.args[0].value)
631
+ if ms is None:
632
+ self._codegen_error(
633
+ node,
634
+ f"timestamp(dateString): could not parse "
635
+ f"'{node.args[0].value}'.",
636
+ hint="Supported forms: ISO-8601 "
637
+ "(\"2025-01-01[THH:MM:SS][±HH:MM]\") and "
638
+ "\"DD MMM YYYY [hh:mm[:ss]] [±HHMM]\" / "
639
+ "\"MMM DD YYYY ...\"; no time zone = "
640
+ "GMT+0.",
641
+ )
642
+ return f"{ms}LL"
643
+ self._codegen_error(
644
+ node,
645
+ "timestamp(dateString) requires a literal string in "
646
+ "PineForge (Pine v6 dateString is a const string).",
647
+ hint="Use a string literal, or timestamp(year, month, "
648
+ "day[, hour, minute, second]).",
649
+ )
650
+ # timezone-first form requires year, month, and day.
651
+ if len(node.args) < 4:
652
+ self._codegen_error(
653
+ node,
654
+ "timestamp(timezone, ...) requires year, month, and "
655
+ "day arguments.",
656
+ hint="Pine v6 signature: timestamp(timezone, year, "
657
+ "month, day[, hour, minute, second]).",
658
+ )
659
+ args = [self._visit_expr(a) for a in node.args]
660
+ tz = args[0]
661
+ yr = args[1] if len(args) > 1 else "1970"
662
+ mo = args[2] if len(args) > 2 else "1"
663
+ dy = args[3] if len(args) > 3 else "1"
664
+ hr = args[4] if len(args) > 4 else "0"
665
+ mn = args[5] if len(args) > 5 else "0"
666
+ sc = args[6] if len(args) > 6 else "0"
667
+ return (
668
+ f"[&]() -> int64_t {{ "
669
+ f"std::string _tz = ({tz}); "
670
+ f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
671
+ f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
672
+ f"static thread_local std::string _last_tz; "
673
+ f"static thread_local int _last_yr = -1, _last_mo = -1, _last_dy = -1, _last_hr = -1, _last_min = -1, _last_sc = -1; "
674
+ f"static thread_local int64_t _last_res = -1; "
675
+ f"if (_last_res != -1 && _last_tz == _tz && _last_yr == _yr && _last_mo == _mo && _last_dy == _dy && _last_hr == _hr && _last_min == _min && _last_sc == _sc) {{ "
676
+ f"return _last_res; "
677
+ f"}} "
678
+ f"struct tm t = {{}}; "
679
+ f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
680
+ f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
681
+ f"int64_t _res; "
682
+ f"if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") {{ "
683
+ f"_res = (int64_t)timegm(&t) * 1000; "
684
+ f"}} else {{ "
685
+ f"static std::mutex _pf_ts_mu; "
686
+ f"std::lock_guard<std::mutex> _pf_ts_mu_lock(_pf_ts_mu); "
687
+ f"const char* _old = std::getenv(\"TZ\"); "
688
+ f"std::string _old_tz = _old ? _old : \"\"; bool _had_old = (_old != nullptr); "
689
+ f"::setenv(\"TZ\", _tz.c_str(), 1); ::tzset(); "
690
+ f"_res = (int64_t)mktime(&t) * 1000; "
691
+ f"if (_had_old) {{ ::setenv(\"TZ\", _old_tz.c_str(), 1); }} "
692
+ f"else {{ ::unsetenv(\"TZ\"); }} ::tzset(); "
693
+ f"}} "
694
+ f"_last_tz = _tz; _last_yr = _yr; _last_mo = _mo; _last_dy = _dy; _last_hr = _hr; _last_min = _min; _last_sc = _sc; "
695
+ f"_last_res = _res; "
696
+ f"return _res; "
697
+ f"}}()"
698
+ )
699
+ else:
700
+ # Numeric form requires year, month, and day (hour/minute/
701
+ # second default to 0). Anything shorter used to emit "0".
702
+ merged = _merge_kwargs(
703
+ node.args, node.kwargs,
704
+ sigs.get_param_names(None, "timestamp"),
705
+ lambda a: a,
706
+ )
707
+ if len(merged) < 3 or any(a is None for a in merged[:3]):
708
+ self._codegen_error(
709
+ node,
710
+ f"timestamp(...) with {len(merged)} argument(s) is "
711
+ f"not supported — year, month, and day are required.",
712
+ hint="Pine v6 signature: timestamp(year, month, day"
713
+ "[, hour, minute, second]); the dateString "
714
+ "overload is not supported in PineForge.",
715
+ )
716
+ args = [self._visit_expr(a) for a in merged]
717
+ yr = args[0]
718
+ mo = args[1] if len(args) > 1 else "1"
719
+ dy = args[2] if len(args) > 2 else "1"
720
+ hr = args[3] if len(args) > 3 else "0"
721
+ mn = args[4] if len(args) > 4 else "0"
722
+ sc = args[5] if len(args) > 5 else "0"
723
+ return (
724
+ f"[&]() -> int64_t {{ "
725
+ f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
726
+ f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
727
+ f"static thread_local int _last_yr = -1, _last_mo = -1, _last_dy = -1, _last_hr = -1, _last_min = -1, _last_sc = -1; "
728
+ f"static thread_local int64_t _last_res = -1; "
729
+ f"if (_last_res != -1 && _last_yr == _yr && _last_mo == _mo && _last_dy == _dy && _last_hr == _hr && _last_min == _min && _last_sc == _sc) {{ "
730
+ f"return _last_res; "
731
+ f"}} "
732
+ f"struct tm t = {{}}; "
733
+ f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
734
+ f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
735
+ f"int64_t _res = (int64_t)timegm(&t) * 1000; "
736
+ f"_last_yr = _yr; _last_mo = _mo; _last_dy = _dy; _last_hr = _hr; _last_min = _min; _last_sc = _sc; "
737
+ f"_last_res = _res; "
738
+ f"return _res; "
739
+ f"}}()"
740
+ )
741
+
742
+ # barssince() — unsupported. Defensive: support_checker rejects bare
743
+ # barssince(...) with a hint to use ta.barssince(...). Reaching here
744
+ # means the checker was bypassed.
745
+ if func_name == "barssince" and namespace is None:
746
+ raise ValueError(
747
+ "codegen: bare barssince(...) is not supported — analyzer should "
748
+ "have rejected. Use ta.barssince(...)."
749
+ )
750
+
751
+ # Type cast functions: int(x), float(x), bool(x), string(x)
752
+ if func_name == "int" and namespace is None and node.args:
753
+ # Pine int(na) → na (int form). Evaluate once, propagate na via
754
+ # the engine's int sentinel instead of collapsing NaN to 0.
755
+ x = self._visit_expr(node.args[0])
756
+ return (f"[&](){{ double _pf_v = (double)({x}); "
757
+ f"return is_na(_pf_v) ? na<int>() : (int)_pf_v; }}()")
758
+ if func_name == "float" and namespace is None and node.args:
759
+ return f"(double)({self._visit_expr(node.args[0])})"
760
+ if func_name == "bool" and namespace is None and node.args:
761
+ return f"(bool)({self._visit_expr(node.args[0])})"
762
+ if func_name == "string" and namespace is None and node.args:
763
+ # Pine string(x) cast — same emission as str.tostring(x), with
764
+ # string passthrough and TV-style "true"/"false" for bools
765
+ # (std::to_string would reject strings / render bools as 0/1).
766
+ arg = node.args[0]
767
+ inferred = self._infer_type(arg)
768
+ if inferred == "std::string":
769
+ return self._visit_expr(arg)
770
+ if inferred == "bool":
771
+ visited = self._visit_expr(arg)
772
+ return f'(({visited}) ? std::string("true") : std::string("false"))'
773
+ return self._visit_str_call("tostring", node)
774
+
775
+ # ta.pivot_point_levels — free function, not a stateful indicator
776
+ if namespace == "ta" and func_name == "pivot_point_levels":
777
+ if node.kwargs:
778
+ args = _merge_kwargs(
779
+ node.args,
780
+ node.kwargs,
781
+ sigs.get_param_names("ta", "pivot_point_levels"),
782
+ self._visit_expr,
783
+ )
784
+ else:
785
+ args = [self._visit_expr(a) for a in node.args]
786
+ if len(args) >= 4:
787
+ return f'ta::pivot_point_levels({", ".join(args[:4])})'
788
+ if 1 <= len(args) <= 3:
789
+ # Pine overload (type, anchor, developing). Per Pine v6
790
+ # semantics, `developing=false` (the default) means the pivot
791
+ # is computed from the LAST CLOSED period's HLC. With
792
+ # `anchor=true` constant, the "period" is one bar, so we
793
+ # consume the PREVIOUS bar's HLC via `_s_high[1]`, etc. The
794
+ # analyzer registers high/low/close in `series_bar_fields` so
795
+ # those `Series<double>` members are guaranteed to exist.
796
+ # Previously we passed `current_bar_.high/low/close` which
797
+ # produced TV-shifted-by-one-bar values for every level.
798
+ return (
799
+ f"ta::pivot_point_levels({args[0]}, _s_high[1], "
800
+ f"_s_low[1], _s_close[1])"
801
+ )
802
+ return f'ta::pivot_point_levels({", ".join(args)})'
803
+
804
+ # Unknown ta.* calls — safe fallback
805
+ if namespace == "ta":
806
+ return f"na<double>() /* unsupported: ta.{func_name} */"
807
+
808
+ if namespace == "syminfo":
809
+ if func_name == "prefix":
810
+ return "_pf_derive_prefix(syminfo_.tickerid)"
811
+ if func_name == "ticker":
812
+ return "syminfo_.ticker"
813
+ return f"na<double>() /* unsupported: syminfo.{func_name} */"
814
+
815
+ # str.* fallback now handled by _visit_str_call above
816
+
817
+ # matrix.* calls
818
+ if namespace == "matrix":
819
+ if func_name == "new":
820
+ targs = self._template_args_from_call(node)
821
+ elem_spec = self._type_spec_from_hint_name(targs[0]) if targs else TypeSpec.primitive("float")
822
+ args_e = [self._visit_expr(a) for a in node.args]
823
+ rows = args_e[0] if args_e else "0"
824
+ cols = args_e[1] if len(args_e) > 1 else "0"
825
+ if elem_spec.kind == "primitive" and elem_spec.name == "float":
826
+ init = args_e[2] if len(args_e) > 2 else "0.0"
827
+ return f"PineMatrix::new_({rows}, {cols}, {init})"
828
+ cpp_t = self._type_spec_to_cpp(elem_spec)
829
+ init = args_e[2] if len(args_e) > 2 else self._default_for_spec(elem_spec)
830
+ return f"PineGenericMatrix<{cpp_t}>::new_({rows}, {cols}, {init})"
831
+ if func_name in MATRIX_METHODS and node.args:
832
+ from ..ast_nodes import Identifier as _Ident
833
+ if func_name in MATRIX_NUMERIC_ONLY:
834
+ if not isinstance(node.args[0], _Ident):
835
+ self._codegen_error(node, f"matrix.{func_name} receiver must be a variable reference")
836
+ recv_name = node.args[0].name
837
+ if recv_name not in self._matrix_specs:
838
+ self._codegen_error(node, f"matrix.{func_name}: receiver '{recv_name}' is not a known matrix variable")
839
+ self._check_matrix_method_allowed(func_name, self._matrix_specs[recv_name], node)
840
+ if func_name == "sort":
841
+ if isinstance(node.args[0], _Ident):
842
+ recv_name = node.args[0].name
843
+ if recv_name in self._matrix_specs:
844
+ self._check_matrix_method_allowed(func_name, self._matrix_specs[recv_name], node)
845
+ obj = self._visit_expr(node.args[0])
846
+ param_names = MATRIX_METHOD_KWARGS.get(func_name)
847
+ if param_names:
848
+ rest = _merge_kwargs(node.args[1:], node.kwargs, param_names, self._visit_expr)
849
+ else:
850
+ rest = [self._visit_expr(a) for a in node.args[1:]]
851
+ fn = MATRIX_METHODS[func_name]
852
+ try:
853
+ return fn(obj, rest)
854
+ except IndexError:
855
+ self._codegen_error(
856
+ node,
857
+ f"matrix.{func_name}: wrong number of arguments",
858
+ hint="Check Pine v6 matrix method signature (positional vs keyword).",
859
+ )
860
+ return "0.0"
861
+
862
+ # log.* calls (log.error, log.warning, log.info)
863
+ if namespace == "log":
864
+ log_funcs = {"info": "pine_log_info", "warning": "pine_log_warning", "error": "pine_log_error"}
865
+ if func_name in log_funcs:
866
+ log_args = [self._visit_expr(a) for a in node.args]
867
+ msg_arg = log_args[0] if log_args else '""'
868
+ return f'{log_funcs[func_name]}({msg_arg})'
869
+ return '"" /* unsupported log */'
870
+
871
+ # timeframe.* calls (e.g., timeframe.change) — not supported in single-TF backtest
872
+ if namespace == "timeframe":
873
+ if func_name == "change":
874
+ tf_arg = self._visit_expr(node.args[0]) if node.args else 'script_tf_'
875
+ return f'tf_change(prev_bar_timestamp_, current_bar_.timestamp, {tf_arg})'
876
+ if func_name == "in_seconds":
877
+ tf_arg = self._visit_expr(node.args[0]) if node.args else 'script_tf_'
878
+ return f'tf_to_seconds({tf_arg})'
879
+ # Defensive: support_checker.NOT_YET_FUNC should already have rejected
880
+ # any unhandled timeframe.* call. Reaching here implies the checker was
881
+ # bypassed.
882
+ raise ValueError(
883
+ f"codegen: unhandled timeframe.{func_name} — analyzer should have "
884
+ f"rejected this. Either add a handler above or extend NOT_YET_FUNC."
885
+ )
886
+
887
+ # UDT constructor: TypeName.new(field=val, ...)
888
+ if namespace in self._udt_defs and func_name == "new":
889
+ fields = self._udt_defs[namespace]
890
+ field_names = [f.name for f in fields]
891
+ init_vals = {}
892
+ for i, a in enumerate(node.args):
893
+ if i < len(field_names):
894
+ init_vals[field_names[i]] = self._visit_expr(a)
895
+ for k, v in node.kwargs.items():
896
+ init_vals[k] = self._visit_expr(v)
897
+ field_inits = []
898
+ field_specs = self._udt_field_type_specs.get(namespace, {})
899
+ for f in fields:
900
+ val = None
901
+ if f.name in init_vals:
902
+ val = init_vals[f.name]
903
+ elif f.default:
904
+ val = self._visit_expr(f.default)
905
+ if val is not None:
906
+ # Fix narrowing: cast na<double>() to correct type for int fields
907
+ f_cpp_type = self._type_spec_to_cpp(field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name))
908
+ if f_cpp_type == "int" and "na<double>" in val:
909
+ val = val.replace("na<double>()", "0")
910
+ field_inits.append(f".{f.name} = {val}")
911
+ return f"{namespace}{{{', '.join(field_inits)}}}"
912
+
913
+ # UDT copy: TypeName.copy(obj)
914
+ if namespace in self._udt_defs and func_name == "copy":
915
+ if node.args:
916
+ return self._visit_expr(node.args[0])
917
+ return f"{namespace}{{}}"
918
+
919
+ # Safety net before the generic emitter. Every builtin namespace and
920
+ # bare builtin that codegen knows how to emit has been dispatched (and
921
+ # returned) above; SKIP_NAMESPACES / SKIP_FUNC_NAMES returned "0";
922
+ # user-defined functions live in ``self._func_names`` and UDT
923
+ # constructors/copies were handled via ``self._udt_defs``. Anything
924
+ # still here would be written out verbatim — ``made_up(...)`` or
925
+ # ``qux::frobnicate(...)`` — i.e. an *undeclared C++ symbol*. That is a
926
+ # silent miscompile: the support checker did not reject it, so the user
927
+ # would otherwise only see a cryptic g++ error pointing at generated
928
+ # C++ instead of their Pine line. Reject loudly with the offending
929
+ # node's location. (Note: any script that reached this branch already
930
+ # failed to compile, so the all-green corpus never exercises it — this
931
+ # only converts garbage output into a clean diagnostic.)
932
+ # ``func_name is None`` means the callee is a complex expression the
933
+ # resolver does not reduce to a simple ``name`` / ``ns.name`` — e.g. a
934
+ # chained method call ``m.transpose().copy()`` whose receiver is itself
935
+ # a FuncCall. Those are handled by the existing generic/chained logic
936
+ # below; do not treat them as unknown builtins.
937
+ if namespace is None and func_name is not None:
938
+ if func_name not in self._func_names:
939
+ self._codegen_error(
940
+ node,
941
+ f"Unknown function '{func_name}(...)' — not a PineForge "
942
+ f"builtin or a user-defined function.",
943
+ hint="Check the spelling; the function may not be supported "
944
+ "by PineForge, or needs its namespace (e.g. math./str.).",
945
+ )
946
+ elif namespace is not None and namespace not in self._udt_defs:
947
+ self._codegen_error(
948
+ node,
949
+ f"Unknown call '{namespace}.{func_name}(...)' — '{namespace}' is "
950
+ f"not a PineForge-supported namespace or a user-defined type.",
951
+ hint="Check the spelling; this namespace may not be supported "
952
+ "by PineForge.",
953
+ )
954
+
955
+ # Generic function call (user-defined or unknown)
956
+ # Determine which params are series (need Series<double> arg, not scalar)
957
+ _func_series_param_indices: set[int] = set()
958
+ fi_lookup = self._func_info_map.get(func_name)
959
+ if fi_lookup and fi_lookup.node:
960
+ func_sv = self.ctx.func_series_vars.get(fi_lookup.name, set())
961
+ for p_idx, p_name in enumerate(fi_lookup.node.params):
962
+ if p_name in func_sv:
963
+ _func_series_param_indices.add(p_idx)
964
+
965
+ def _visit_arg_for_series(arg_node, arg_idx):
966
+ """Visit a function argument, returning Series ref for series params."""
967
+ if arg_idx in _func_series_param_indices and isinstance(arg_node, Identifier):
968
+ aname = arg_node.name
969
+ # Bar field: pass _s_close instead of current_bar_.close
970
+ if aname in BAR_FIELDS or aname in BAR_SERIES_PUSH:
971
+ return f"_s_{aname}"
972
+ # Series var: pass the Series object directly
973
+ if aname in self.ctx.series_vars:
974
+ safe = self._safe_name(aname)
975
+ if self._active_var_remap and safe in self._active_var_remap:
976
+ safe = self._active_var_remap[safe]
977
+ return safe
978
+ return self._visit_expr(arg_node)
979
+
980
+ if node.kwargs:
981
+ # Try to resolve kwargs using FuncInfo params for user-defined functions
982
+ fi = self._func_info_map.get(func_name)
983
+ if fi and fi.node and fi.node.params:
984
+ param_names = list(fi.node.params) # params is list[str]
985
+ # Merge kwargs then visit with series awareness
986
+ merged = _merge_kwargs(node.args, node.kwargs, param_names, lambda a: a)
987
+ all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(merged)]
988
+ elif sigs.is_intrinsic_function(namespace, func_name):
989
+ # Known intrinsic — use signature registry for kwargs resolution
990
+ param_names = sigs.get_param_names(namespace, func_name)
991
+ all_args = _merge_kwargs(node.args, node.kwargs, param_names, self._visit_expr)
992
+ else:
993
+ # Unknown function: positional args + kwargs values as fallback
994
+ all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
995
+ all_args.extend(self._visit_expr(v) for v in node.kwargs.values())
996
+ else:
997
+ all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
998
+ # Default args (parser does not store defaults): isInSession(sess, res = timeframe.period)
999
+ if namespace is None and func_name in self._func_names:
1000
+ fi = self._func_info_map.get(func_name)
1001
+ if fi and fi.node and fi.name == "isInSession" and len(fi.node.params) >= 2 and len(all_args) == 1:
1002
+ # Mirror Pine default `timeframe.period` instead of hard-coding 15m.
1003
+ all_args.append("script_tf_")
1004
+ prefix = f"{namespace}::" if namespace else ""
1005
+ # Use safe name for user-defined functions to avoid member name collision
1006
+ emit_name = self._func_safe_name(func_name) if func_name in self._func_names else func_name
1007
+ # Per-call-site variant: if this function has TA/series calls, call the correct variant
1008
+ cs_info = self.ctx.func_call_cs_map.get(id(node))
1009
+ if self._active_call_site_idx is not None and cs_info is not None:
1010
+ # Inside a per-call-site variant: override the cs_map index with
1011
+ # the parent's active call-site index. This ensures sub-functions
1012
+ # called from ma_cs6() use their _cs6 variant, not _cs0.
1013
+ fname, _ = cs_info
1014
+ emit_name = f"{self._func_safe_name(fname)}_cs{self._active_call_site_idx}"
1015
+ elif cs_info is not None:
1016
+ fname, cs_idx = cs_info
1017
+ emit_name = f"{self._func_safe_name(fname)}_cs{cs_idx}"
1018
+ elif (self._active_call_site_idx is not None
1019
+ and func_name in self._func_names
1020
+ and self.ctx.func_call_site_counts.get(func_name, 0) > 1):
1021
+ # Inside a per-call-site variant: propagate call-site index to
1022
+ # sub-functions that also have variants (for state isolation)
1023
+ emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}"
1024
+ return f"{prefix}{emit_name}({', '.join(all_args)})"
1025
+
1026
+ def _visit_fixnan(self, node: FuncCall) -> str:
1027
+ """Emit fixnan with persistent state member."""
1028
+ self._fixnan_counter += 1
1029
+ member = f"_prev_fixnan_{self._fixnan_counter}"
1030
+ x = self._visit_expr(node.args[0])
1031
+ return f"(is_na({x}) ? {member} : ({member} = {x}))"
1032
+
1033
+ def _visit_strategy_call(self, func_name: str, node: FuncCall) -> str:
1034
+ if func_name in ("convert_to_account", "convert_to_symbol"):
1035
+ p = self._resolve_func_args(node, f"strategy.{func_name}")
1036
+ v = self._visit_expr(p.get("value")) if p.get("value") is not None else "0.0"
1037
+ return f"({v})"
1038
+ if func_name == "default_entry_qty":
1039
+ p = self._resolve_func_args(node, "strategy.default_entry_qty")
1040
+ fp = self._visit_expr(p.get("fill_price")) if p.get("fill_price") is not None else "0.0"
1041
+ return f"calc_qty({fp})"
1042
+
1043
+ if func_name == "entry":
1044
+ p = self._resolve_func_args(node, "strategy.entry")
1045
+ entry_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1046
+ direction = self._visit_expr(p.get("direction")) if "direction" in p else "true"
1047
+ stop = p.get("stop")
1048
+ limit = p.get("limit")
1049
+ qty = p.get("qty")
1050
+ comment = p.get("comment")
1051
+ oca_name = p.get("oca_name")
1052
+ oca_type = p.get("oca_type")
1053
+ qty_type = p.get("qty_type")
1054
+ comment_val = self._visit_expr(comment) if comment else '""'
1055
+ oca_name_val = self._visit_expr(oca_name) if oca_name else '""'
1056
+ oca_type_val = self._visit_expr(oca_type) if oca_type else "0"
1057
+ qty_type_val = self._visit_expr(qty_type) if qty_type else "-1"
1058
+ qty_val = self._visit_expr(qty) if qty else "na<double>()"
1059
+ if stop is not None or limit is not None or qty is not None or oca_name is not None or oca_type is not None or qty_type is not None:
1060
+ limit_val = self._visit_expr(limit) if limit else "na<double>()"
1061
+ stop_val = self._visit_expr(stop) if stop else "na<double>()"
1062
+ # pineforge-engine v0.2 dropped the vestigial `market_price`
1063
+ # third positional from `BacktestEngine::strategy_entry`
1064
+ # (the runtime never read it; fill price always came from
1065
+ # current_bar_.close inside the function body). Codegen now
1066
+ # matches the new signature: (id, direction, limit, stop,
1067
+ # qty, comment, oca_name, oca_type, qty_type).
1068
+ return f"strategy_entry({entry_id}, {direction}, {limit_val}, {stop_val}, {qty_val}, {comment_val}, {oca_name_val}, {oca_type_val}, {qty_type_val})"
1069
+ return f"strategy_entry({entry_id}, {direction}, na<double>(), na<double>(), na<double>(), {comment_val})"
1070
+
1071
+ if func_name == "close":
1072
+ p = self._resolve_func_args(node, "strategy.close")
1073
+ close_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1074
+ comment = self._visit_expr(p.get("comment")) if p.get("comment") is not None else '""'
1075
+ qty = self._visit_expr(p.get("qty")) if p.get("qty") is not None else "na<double>()"
1076
+ qty_pct = self._visit_expr(p.get("qty_percent")) if p.get("qty_percent") is not None else "na<double>()"
1077
+ immediately = self._visit_expr(p.get("immediately")) if p.get("immediately") is not None else "false"
1078
+ return f"strategy_close({close_id}, {comment}, {qty}, {qty_pct}, {immediately})"
1079
+
1080
+ if func_name == "close_all":
1081
+ return "strategy_close_all()"
1082
+
1083
+ if func_name == "exit":
1084
+ p = self._resolve_func_args(node, "strategy.exit")
1085
+ exit_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1086
+ from_id = self._visit_expr(p.get("from_entry")) if "from_entry" in p else '""'
1087
+
1088
+ limit_n = p.get("limit")
1089
+ stop_n = p.get("stop")
1090
+ profit_n = p.get("profit")
1091
+ loss_n = p.get("loss")
1092
+ trail_pts_n = p.get("trail_points")
1093
+ trail_off_n = p.get("trail_offset")
1094
+ trail_pr_n = p.get("trail_price")
1095
+ qty_pct_n = p.get("qty_percent")
1096
+ qty_n = p.get("qty")
1097
+ comment_n = p.get("comment")
1098
+ oca_name_n = p.get("oca_name")
1099
+
1100
+ has_price_exit = any(x is not None for x in
1101
+ [limit_n, stop_n, profit_n, loss_n,
1102
+ trail_pts_n, trail_off_n, trail_pr_n])
1103
+ if has_price_exit:
1104
+ limit_val = self._visit_expr(limit_n) if limit_n else "na<double>()"
1105
+ stop_val = self._visit_expr(stop_n) if stop_n else "na<double>()"
1106
+ trail_pts = self._visit_expr(trail_pts_n) if trail_pts_n else "na<double>()"
1107
+ trail_off = self._visit_expr(trail_off_n) if trail_off_n else "na<double>()"
1108
+ trail_pr = self._visit_expr(trail_pr_n) if trail_pr_n else "na<double>()"
1109
+ qty_pct = self._visit_expr(qty_pct_n) if qty_pct_n else "100.0"
1110
+ qty_val = self._visit_expr(qty_n) if qty_n else "na<double>()"
1111
+ comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1112
+ oca_val = self._visit_expr(oca_name_n) if oca_name_n is not None else '""'
1113
+
1114
+ if profit_n and not limit_n:
1115
+ ticks = self._visit_expr(profit_n)
1116
+ limit_val = f"(position_entry_price_ + (signed_position_size() > 0 ? 1.0 : -1.0) * ({ticks}) * syminfo_mintick_)"
1117
+ if loss_n and not stop_n:
1118
+ ticks = self._visit_expr(loss_n)
1119
+ stop_val = f"(position_entry_price_ - (signed_position_size() > 0 ? 1.0 : -1.0) * ({ticks}) * syminfo_mintick_)"
1120
+
1121
+ return (f"strategy_exit({exit_id}, {from_id}, {limit_val}, {stop_val}, "
1122
+ f"{trail_pts}, {trail_off}, {trail_pr}, {qty_pct}, {comment}, "
1123
+ f"{qty_val}, {oca_val})")
1124
+ close_comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1125
+ return f"strategy_close({exit_id}, {close_comment})"
1126
+
1127
+ if func_name == "cancel":
1128
+ p = self._resolve_func_args(node, "strategy.close") # same shape: id first
1129
+ cancel_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1130
+ return f"strategy_cancel({cancel_id})"
1131
+
1132
+ if func_name == "cancel_all":
1133
+ return "strategy_cancel_all()"
1134
+
1135
+ if func_name == "order":
1136
+ p = self._resolve_func_args(node, "strategy.order")
1137
+ order_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1138
+ direction = self._visit_expr(p.get("direction")) if "direction" in p else "true"
1139
+ qty = self._visit_expr(p.get("qty")) if "qty" in p else "0"
1140
+ limit_arg = self._visit_expr(p.get("limit")) if "limit" in p else "na<double>()"
1141
+ stop_arg = self._visit_expr(p.get("stop")) if "stop" in p else "na<double>()"
1142
+ oca_name = self._visit_expr(p.get("oca_name")) if "oca_name" in p else '""'
1143
+ oca_type = self._visit_expr(p.get("oca_type")) if "oca_type" in p else "0"
1144
+ return f"strategy_order({order_id}, {direction}, {qty}, {limit_arg}, {stop_arg}, {oca_name}, {oca_type})"
1145
+
1146
+ if func_name == "risk":
1147
+ return "/* skip */"
1148
+
1149
+ # strategy.closedtrades.*(idx) / strategy.opentrades.*(idx)
1150
+ # These come through as func_name="profit" etc. with nested callee
1151
+ if isinstance(node.callee, MemberAccess):
1152
+ inner = node.callee.object
1153
+ if isinstance(inner, MemberAccess) and inner.member in ("closedtrades", "opentrades"):
1154
+ idx = self._visit_expr(node.args[0]) if node.args else "0"
1155
+ is_open = inner.member == "opentrades"
1156
+ # Open trades have no exit metadata in Pine
1157
+ if is_open and func_name in (
1158
+ "exit_price", "exit_time", "exit_comment", "exit_id", "exit_bar_index",
1159
+ ):
1160
+ if func_name == "exit_bar_index":
1161
+ return "na<int>()"
1162
+ if func_name == "exit_time":
1163
+ return "0"
1164
+ if func_name == "exit_price":
1165
+ return "na<double>()"
1166
+ if func_name in ("exit_comment", "exit_id"):
1167
+ return "std::string()"
1168
+
1169
+ prefix = "open_trade_" if is_open else "closed_trade_"
1170
+ suffix_map = {
1171
+ "profit": "profit",
1172
+ "profit_percent": "profit_percent",
1173
+ "commission": "commission",
1174
+ "direction": "direction",
1175
+ "entry_bar_index": "entry_bar_index",
1176
+ "exit_bar_index": "exit_bar_index",
1177
+ "entry_comment": "entry_comment",
1178
+ "exit_comment": "exit_comment",
1179
+ "entry_id": "entry_id",
1180
+ "exit_id": "exit_id",
1181
+ "entry_price": "entry_price",
1182
+ "exit_price": "exit_price",
1183
+ "entry_time": "entry_time",
1184
+ "exit_time": "exit_time",
1185
+ "size": "size",
1186
+ "max_runup": "max_runup",
1187
+ "max_runup_percent": "max_runup_percent",
1188
+ "max_drawdown": "max_drawdown",
1189
+ "max_drawdown_percent": "max_drawdown_percent",
1190
+ }
1191
+ fn = suffix_map.get(func_name, "profit")
1192
+ return f"{prefix}{fn}({idx})"
1193
+
1194
+ # Defensive: support_checker rejects unknown strategy.* calls (name not
1195
+ # in sigs.STRATEGY_FUNCTIONS) and unknown strategy.closedtrades.* /
1196
+ # strategy.opentrades.* accessors (not in the side-specific accessor
1197
+ # whitelists). Reaching here means the checker was bypassed or drifted.
1198
+ raise ValueError(
1199
+ f"codegen: unhandled strategy.{func_name}(...) — analyzer should "
1200
+ f"have rejected. Add a handler above or extend STRATEGY_FUNCTIONS."
1201
+ )
1202
+
1203
+ def _visit_color_call(self, func_name: str, node) -> str:
1204
+ """Emit color.* calls as integer representations."""
1205
+ args = [self._visit_expr(a) for a in node.args]
1206
+ if func_name == "new":
1207
+ if len(args) >= 2:
1208
+ return f'pine_color::new_color({args[0]}, (int)({args[1]}))'
1209
+ return "0"
1210
+ if func_name in ("r", "g", "b", "t"):
1211
+ if args:
1212
+ return f'pine_color::{func_name}({args[0]})'
1213
+ return "0"
1214
+ if func_name == "rgb":
1215
+ if len(args) >= 4:
1216
+ return f"pine_color::new_color(((int64_t)({args[0]}) << 16 | (int64_t)({args[1]}) << 8 | (int64_t)({args[2]})), (int)({args[3]}))"
1217
+ elif len(args) >= 3:
1218
+ return f"pine_color::new_color(((int64_t)({args[0]}) << 16 | (int64_t)({args[1]}) << 8 | (int64_t)({args[2]})), 0)"
1219
+ return "0"
1220
+ if func_name == "from_gradient":
1221
+ return "0"
1222
+ return "0"
1223
+
1224
+ def _visit_str_call(self, func_name: str, node) -> str:
1225
+ args = _merge_kwargs(node.args, node.kwargs,
1226
+ sigs.get_param_names("str", func_name), self._visit_expr)
1227
+
1228
+ if func_name == "tostring":
1229
+ # Pine: str.tostring(enumVar) → field title / IANA string, not the int index
1230
+ val_arg = node.args[0] if node.args else node.kwargs.get("value")
1231
+ if isinstance(val_arg, Identifier):
1232
+ sym = self.ctx.symbols.resolve(val_arg.name)
1233
+ if sym is not None and sym.enum_type_name:
1234
+ et = sym.enum_type_name
1235
+ tbl = self._enum_member_strings.get(et)
1236
+ if tbl:
1237
+ var = self._safe_name(val_arg.name)
1238
+ n = len(tbl)
1239
+ return (
1240
+ f"pine_enum_str_at({et}_str_values, {n}, {var})"
1241
+ )
1242
+ if len(args) >= 2:
1243
+ return f"pine_str_tostring({args[0]}, {args[1]}, syminfo_mintick_)"
1244
+ if len(args) >= 1:
1245
+ return f"std::to_string({args[0]})"
1246
+ return 'std::string("")'
1247
+
1248
+ if func_name == "substring":
1249
+ if len(args) == 3:
1250
+ return f"{args[0]}.substr({args[1]}, {args[2]} - {args[1]})"
1251
+ elif len(args) == 2:
1252
+ return f"{args[0]}.substr({args[1]})"
1253
+ return 'std::string("")'
1254
+
1255
+ if func_name == "format":
1256
+ # str.format is variadic: signature has only ``formatStr``; the
1257
+ # remaining args are placeholder substitutions. The runtime
1258
+ # ``pine_str_format(fmt, vector<string>)`` requires every arg
1259
+ # already converted to ``std::string``. We previously gated the
1260
+ # ``std::to_string`` wrap on a source-text-prefix heuristic
1261
+ # (``"`` / ``std::string`` / ``pine_str``), which mis-classified
1262
+ # any string-typed bare identifier or string-returning helper
1263
+ # call (e.g. ``str.tostring(x)`` bound to a variable). The type
1264
+ # check below uses the analyzer's inferred PineType instead so
1265
+ # ``std::string`` args pass through unchanged.
1266
+ if node.args:
1267
+ fmt_arg = self._visit_expr(node.args[0])
1268
+ rest = []
1269
+ for orig in node.args[1:]:
1270
+ visited = self._visit_expr(orig)
1271
+ inferred = self._infer_type(orig)
1272
+ if inferred == "std::string":
1273
+ rest.append(visited)
1274
+ continue
1275
+ # Booleans render as 0/1 via std::to_string; force the
1276
+ # TV-style "true"/"false" output so backtest logs and
1277
+ # alert messages line up with the TradingView side.
1278
+ if inferred == "bool":
1279
+ rest.append(
1280
+ f'({visited} ? std::string("true") : std::string("false"))'
1281
+ )
1282
+ continue
1283
+ rest.append(f'std::to_string({visited})')
1284
+ if rest:
1285
+ vec = "{" + ", ".join(rest) + "}"
1286
+ return f'pine_str_format({fmt_arg}, {vec})'
1287
+ return fmt_arg
1288
+ return 'std::string("")'
1289
+
1290
+ if func_name == "format_time":
1291
+ ts = args[0] if args else "0"
1292
+ fmt = args[1] if len(args) > 1 else '"yyyy-MM-dd"'
1293
+ tz = args[2] if len(args) > 2 else '"UTC"'
1294
+ return f'pine_str_format_time({ts}, {fmt}, {tz})'
1295
+
1296
+ if func_name == "replace":
1297
+ if len(args) >= 4:
1298
+ # 4-arg form: replace the Nth occurrence (0-based, per Pine
1299
+ # spec). Out-of-range / negative occurrence → original string.
1300
+ return (
1301
+ f'[&](){{ std::string s={args[0]}; std::string t={args[1]}; '
1302
+ f'std::string r={args[2]}; int _occ=(int)({args[3]}); '
1303
+ f'if(t.empty()||_occ<0) return s; '
1304
+ f'size_t p=0; int _i=0; '
1305
+ f'while((p=s.find(t,p))!=std::string::npos){{ '
1306
+ f'if(_i==_occ){{ s.replace(p,t.length(),r); break; }} '
1307
+ f'p+=t.length(); _i++; }} return s; }}()'
1308
+ )
1309
+ if len(args) >= 3:
1310
+ return f'[&](){{ std::string s={args[0]}; auto p=s.find({args[1]}); if(p!=std::string::npos) s.replace(p,{args[1]}.length(),{args[2]}); return s; }}()'
1311
+ return 'std::string("")'
1312
+
1313
+ if func_name in STR_FUNC_MAP and STR_FUNC_MAP[func_name] is not None:
1314
+ return STR_FUNC_MAP[func_name](args)
1315
+
1316
+ return f'std::string("") /* unsupported: str.{func_name} */'
1317
+
1318
+ def _visit_math_call(self, func_name: str, node: FuncCall) -> str:
1319
+ args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names("math", func_name), self._visit_expr)
1320
+ # Handle special cases first
1321
+ if func_name == "round" and len(args) == 2:
1322
+ return f"(std::round({args[0]} * std::pow(10.0, {args[1]})) / std::pow(10.0, {args[1]}))"
1323
+ if func_name == "round_to_mintick":
1324
+ # Engine method (engine.hpp): NaN- and mintick<=0-guarded,
1325
+ # unlike the previous inlined unguarded std::round.
1326
+ x = args[0] if args else "0.0"
1327
+ return f"round_to_mintick({x})"
1328
+ if func_name == "todegrees":
1329
+ x = args[0] if args else "0.0"
1330
+ return f"({x} * 180.0 / M_PI)"
1331
+ if func_name == "toradians":
1332
+ x = args[0] if args else "0.0"
1333
+ return f"({x} * M_PI / 180.0)"
1334
+ if func_name == "random":
1335
+ lo = args[0] if len(args) > 0 else "0.0"
1336
+ hi = args[1] if len(args) > 1 else "1.0"
1337
+ seed = args[2] if len(args) > 2 else "0"
1338
+ call_site = self._random_call_counter
1339
+ self._random_call_counter += 1
1340
+ return f"pine_random({lo}, {call_site}u, {hi}, (uint32_t)({seed}), bar_index_)"
1341
+ if func_name == "avg" and len(args) > 2:
1342
+ sum_expr = " + ".join(f"(double)({a})" for a in args)
1343
+ return f"(({sum_expr}) / {len(args)}.0)"
1344
+ if func_name == "max" and len(args) > 2:
1345
+ result = f"std::max((double)({args[0]}), (double)({args[1]}))"
1346
+ for a in args[2:]:
1347
+ result = f"std::max({result}, (double)({a}))"
1348
+ return result
1349
+ if func_name == "min" and len(args) > 2:
1350
+ result = f"std::min((double)({args[0]}), (double)({args[1]}))"
1351
+ for a in args[2:]:
1352
+ result = f"std::min({result}, (double)({a}))"
1353
+ return result
1354
+ if func_name in MATH_FUNC_MAP:
1355
+ mapped = MATH_FUNC_MAP[func_name]
1356
+ if "{0}" in mapped:
1357
+ return mapped.format(*args)
1358
+ # std::min/std::max require same types — cast to double
1359
+ if func_name in ("min", "max") and len(args) == 2:
1360
+ return f"{mapped}((double)({args[0]}), (double)({args[1]}))"
1361
+ return f"{mapped}({', '.join(args)})"
1362
+ # Unknown math.* — safe fallback
1363
+ return f"0.0 /* unsupported: math.{func_name} */"
1364
+
1365
+ # ------------------------------------------------------------------
1366
+ # Arg/kwarg resolution (PineScript parameter signatures)
1367
+ # ------------------------------------------------------------------
1368
+
1369
+ def _resolve_func_args(self, node: FuncCall, sig_key: str) -> dict:
1370
+ """Merge positional args and kwargs into a dict keyed by parameter name.
1371
+
1372
+ Uses the PineScript parameter ordering from signatures registry.
1373
+ """
1374
+ # sig_key is like "strategy.entry" -> namespace="strategy", func_name="entry"
1375
+ parts = sig_key.split(".", 1)
1376
+ if len(parts) == 2:
1377
+ param_names = sigs.get_param_names(parts[0], parts[1]) or []
1378
+ else:
1379
+ param_names = sigs.get_param_names(None, sig_key) or []
1380
+ result: dict = {}
1381
+ # Map positional args by parameter name
1382
+ for i, arg in enumerate(node.args):
1383
+ if i < len(param_names):
1384
+ result[param_names[i]] = arg
1385
+ # kwargs override positional
1386
+ result.update(node.kwargs)
1387
+ return result